Avoiding a flash of incorrect theme in Astro
| Updated | 12 May 2024 |
| Published | 5 May 2024 |
If you apply a users theme preference from localStorage or from the prefers-color-scheme media query in Astro, you will probably notice a flash of the incorrect theme being applied on page load. This flash of an incorrect theme will be even more noticeable if you have transitions applied to colour changes.
This happens because the page content is being rendered with the default theme before your JavaScript can apply the correct theme. A solution to this problem is to apply the correct theme from a script in the <head> before the browser renders the page. This ensures that the correct theme is applied as early as possible, preventing the brief flash of the incorrect theme.
Here is the script we will put in the head:
;(() => { const preference = // if localStorage theme already exists, use that localStorage.getItem('theme') ?? // else use user's system preference (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
document.documentElement.classList.toggle('dark', preference === 'dark')
// persist theme to localStorage when mutation observed on <html> const themeObserver = new MutationObserver(() => { const isDark = document.documentElement.classList.contains('dark') localStorage.setItem('theme', isDark ? 'dark' : 'light') }) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })})()This script needs to be placed in the <head> of your HTML document and executed inline using the is:inline directive. Here’s why:
When a web page is loaded, the browser parses the HTML document from top to bottom. As it encounters each element in the <head>, it parses and executes them in order. By placing our persistTheme script in the <head> with the is:inline directive, we ensure that it is executed as soon as the browser encounters it, while still in the process of parsing the <head>. This happens before the browser moves on to parse and render the <body>.
Executing the script at this early stage allows it to apply the correct theme before any content is visually rendered to the user, avoiding the flash of incorrect theme that would occur if the theme was applied later in the page load process.
To minify the script, you can run:
terser src/lib/persistTheme.js --mangle --output src/lib/persistTheme.min.jsor copy:
(()=>{const e=localStorage.getItem("theme")??(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");document.documentElement.classList.toggle("dark",e==="dark");const t=new MutationObserver((()=>{const e=document.documentElement.classList.contains("dark");localStorage.setItem("theme",e?"dark":"light")}));t.observe(document.documentElement,{attributes:true,attributeFilter:["class"]})})();Including the script
- Import the minified script as a string using the
?rawsuffix. - Add the script to the
<head>using theis:inlinedirective.
is:inlinetells Astro to leave the<script>or<style>tag as-is in the final output HTML. The contents will not be processed, optimized, or bundled.
---import persistTheme from '@/lib/persistTheme.min.js?raw'---
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script is:inline set:html={persistTheme} /> ...Theme toggle UI
This script relies on the dark theme being applied by adding a "dark" class to the <html> element. For a simple way to toggle dark mode in this way with an .astro component, see this tutorial from the Astro docs:
Back on dry land. Take your blog from day to night, no island required!
Below is my implementation of a theme toggle button. It is a Vue component designed to simply toggle the "dark" class on the <html> element. It uses the <Toggle> component from shadcn-vue.
Note: This component relies on the tailwind darkMode: 'selector' value to be set in the tailwind config. This allows us to use the dark: prefix, where the class following dark: will be applied when the 'dark' class is in the <html> element class list.
<script setup lang="ts">import { onMounted, ref, watch } from 'vue'
import IconMoon from '@/components/icons/IconMoon.vue'import IconSun from '@/components/icons/IconSun.vue'import { Toggle } from '@/components/ui/toggle'
const isDark = ref(false)
onMounted( () => (isDark.value = document.documentElement.classList.contains('dark')))
watch(isDark, (val) => document.documentElement.classList.toggle('dark', val))</script>
<template> <Toggle v-model:pressed="isDark" size="icon" variant="theme" aria-label="Toggle dark mode" > <IconSun class="block h-6 w-6 dark:hidden" /> <IconMoon class="hidden h-6 w-6 dark:block" /> </Toggle></template>The icons are conditionally displayed with CSS. Since we are setting the dark theme with the script in the head, by the time the ThemeToggle is rendered the correct theme will already be applied, so the correct icon will be visible.
Add this to your Layout with client:load so that it can be rendered on the server and hydrated immediately on page load.
<ThemeToggle client:load />You could also use client:idle, but using client:only="vue" will result in a flash where the ThemeToggle isn’t visible as it will be skipped at build time.
Additional Resources
For more information about implementing dark mode, read:
A Complete Guide to Dark Mode on the Web
My solution was inspired by this comment on Reddit:
/r/webdev/comments/16iu3uf/comment/k0n4ds2