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.js
or 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
?raw
suffix. - Add the script to the
<head>
using theis:inline
directive.
is:inline
tells 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} /> ...
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