Ditching the Theme Toggle: System Theme Detection in Astro
The Problem
Most sites ship a theme toggle and call it done. But there are three separate problems hiding in “dark mode support”:
- Initial paint — the page needs to know the theme before the first pixel renders
- Reactive switching — when the user changes the theme in your UI, the app needs to respond
- System preference changes — when someone toggles dark mode at the OS level while your site is open
Each one needs a different solution. Here’s how I handled all three.
1. Flash Prevention: The Inline Script
This runs in <head> before the browser paints anything. It’s the most critical piece — without it, dark mode users get a white flash on every page load.
<script>
(() => {
try {
const theme = localStorage.getItem("theme") || "system";
const resolved = theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.style.background = resolved === "dark" ? "#000" : "#fff";
} catch {}
})();
</script>
A few decisions worth explaining:
localStorage.getItem("theme") || "system"— three possible states:"dark","light", or"system". Default is system, meaning the OS decides.root.style.background— setting the background color directly on the root element catches the very first frame. CSS classes can’t beat an inline style for speed here.try/catch—localStoragecan throw in private browsing or restricted contexts. Silent failure is fine — the page just falls back to the default.- IIFE — keeps variables out of global scope. Small thing, but good hygiene.
In Astro, this goes in your base layout with is:inline so it doesn’t get bundled and deferred:
<head>
<!-- Theme init — runs before paint -->
<script is:inline>
(() => {
try {
const theme = localStorage.getItem("theme") || "system";
const resolved = theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.style.background = resolved === "dark" ? "#000" : "#fff";
} catch {}
})();
</script>
</head>
2. Reactive Theme Switching: useEffect
When the user changes the theme through your UI (or you want to support it later), you need the DOM to react. This is where React comes in:
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
root.style.background = "";
if (theme === "system") {
const systemTheme = prefersDarkMode() ? "dark" : "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
What’s happening:
- Strip both classes first — clean slate on every change. No stale state.
- Clear inline background — the inline script set
root.style.backgroundfor the initial paint. Once React hydrates, CSS takes over, so clear it out. - Resolve
"system"at runtime — if the user picked “system”, checkmatchMediaright now and apply the result. [theme]dependency — only re-runs when the theme state actually changes.
The prefersDarkMode helper is simple:
const prefersDarkMode = () =>
window.matchMedia("(prefers-color-scheme: dark)").matches;
3. Live System Changes: matchMedia Listener
If someone switches their OS from light to dark while your site is open, you want to catch it:
useEffect(() => {
const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () =>
setTheme(prefersDarkMode() ? "dark" : "light");
darkThemeMq.addEventListener("change", handleChange);
return () => darkThemeMq.removeEventListener("change", handleChange);
}, []);
This is a separate effect with an empty dependency array — it mounts once and cleans up on unmount. The change event fires whenever the OS-level preference flips, and it updates your React state, which triggers the first useEffect to apply the new class.
Shiki Dual-Theme Syntax Highlighting
Code blocks need to respect the theme too. Astro’s Shiki integration supports dual themes:
// astro.config.mjs
export default defineConfig({
markdown: {
shikiConfig: {
themes: {
light: "github-light",
dark: "github-dark",
},
},
},
});
Shiki sets light theme colors as inline styles and stashes dark values as CSS custom properties (--shiki-dark). You need CSS to swap them:
/* Dark theme: swap to Shiki's CSS custom properties */
html.dark .astro-code {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
html.dark .astro-code span {
color: var(--shiki-dark) !important;
background-color: transparent !important;
}
The !important flags override Shiki’s inline styles. The transparent background on spans prevents each token from getting its own background box.
Tailwind Typography Gotcha
If you’re using @tailwindcss/typography for blog content, its prose styles will try to style <code> elements inside <pre> blocks — adding backgrounds, padding, and border-radius that conflict with Shiki. Scope your inline code styles to exclude code inside pre:
/* Inline code only — not inside pre blocks */
.prose :where(code):not(:where(pre *, [class~="not-prose"], [class~="not-prose"] *)) {
@apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm;
}
/* Reset code inside pre — let Shiki own it */
.prose :where(pre code):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
}
The Full Picture
Three layers, each solving a different timing problem:
| Layer | When | What |
|---|---|---|
Inline <script> | Before first paint | Prevents flash, sets initial theme |
useEffect([theme]) | On theme state change | Applies class, clears inline styles |
useEffect([]) | On mount | Listens for OS-level theme changes |
The inline script is the foundation. The React effects handle everything after hydration. Together they cover every edge case I’ve hit — initial load, manual switching, and live system changes.