Astro docs suggest implementing a dark mode like so.
That’s great and works fine for initial implementation but runs into issues when using the ClientRouter for those j00cy built-in page transitions.
Namely it’s great toggling on first load but falls flat after navigating to different pages on your site until you hit the hard server rendered refresh (that’s F5 to you and me).
With some workarounds you can get the best of all worlds:
- Great light/dark (why stop at two?) themes to toggle between
- Persistence between client side page routes
- Beautiful Astro page transitions
- Respects the user’s
prefers-color-scheme: darkpreference - No flash of wrong theme on load
The Flash Problem
The classic dark mode flash: page loads light, then snaps to dark. Jarring as hell. The fix is applying the theme before the page renders, using an inline script in <head>:
<script is:inline>
(function() {
function applyTheme() {
const theme = localStorage.getItem('theme');
document.documentElement.classList.remove('light', 'dark', 'terminal');
if (theme && ['light', 'dark', 'terminal'].includes(theme)) {
document.documentElement.classList.add(theme);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
}
applyTheme();
// Re-apply after Astro page transitions
document.addEventListener('astro:after-swap', applyTheme);
})();
</script>
This runs synchronously before paint. No flash. The astro:after-swap listener handles page transitions.
The Theme Toggle Component
Here’s the code for src/components/ThemeIcon.astro:
---
---
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
<span id="theme-toggle-dark-icon">☀️</span>
<span id="theme-toggle-light-icon">🌙</span>
<span id="theme-toggle-terminal-icon">🖥️</span>
</button>
<style>
.theme-toggle {
border: none;
background: none;
cursor: pointer;
padding: 0.5rem;
font-size: 1.2rem;
}
.hidden {
display: none;
}
</style>
<script is:inline>
const STORAGE_KEY = "theme";
const THEMES = ["light", "dark", "terminal"];
function updateTheme(newTheme) {
document.documentElement.classList.remove(...THEMES);
document.documentElement.classList.add(newTheme);
// Update icons - show the icon for the CURRENT theme
const darkIcon = document.getElementById("theme-toggle-dark-icon");
const lightIcon = document.getElementById("theme-toggle-light-icon");
const terminalIcon = document.getElementById("theme-toggle-terminal-icon");
if (darkIcon && lightIcon && terminalIcon) {
darkIcon.classList.toggle("hidden", newTheme !== "light");
lightIcon.classList.toggle("hidden", newTheme !== "dark");
terminalIcon.classList.toggle("hidden", newTheme !== "terminal");
}
localStorage.setItem(STORAGE_KEY, newTheme);
}
function getNextTheme(currentTheme) {
const currentIndex = THEMES.indexOf(currentTheme);
return THEMES[(currentIndex + 1) % THEMES.length];
}
function setupTheme() {
const theme = (() => {
if (typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY)) {
return localStorage.getItem(STORAGE_KEY);
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
})();
updateTheme(theme);
const toggle = document.getElementById("theme-toggle");
if (toggle) {
toggle.addEventListener("click", () => {
const currentTheme = localStorage.getItem(STORAGE_KEY) || "light";
updateTheme(getNextTheme(currentTheme));
});
}
}
setupTheme();
// Handle Astro view transitions
document.addEventListener("astro:after-swap", setupTheme);
// Handle system theme changes
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (!localStorage.getItem(STORAGE_KEY)) {
updateTheme(e.matches ? "dark" : "light");
}
});
</script>
BaseLayout with Page Transitions
Chuck that component in your main layout along with the ClientRouter and flash prevention script:
---
const { title } = Astro.props;
import { ClientRouter } from "astro:transitions";
import ThemeIcon from '../components/ThemeIcon.astro';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<ClientRouter />
<script is:inline>
// Prevent flash - runs before paint
(function() {
function applyTheme() {
const theme = localStorage.getItem('theme');
document.documentElement.classList.remove('light', 'dark', 'terminal');
if (theme && ['light', 'dark', 'terminal'].includes(theme)) {
document.documentElement.classList.add(theme);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
}
applyTheme();
document.addEventListener('astro:after-swap', applyTheme);
})();
</script>
</head>
<body>
<nav>
<a href="/">home</a>
<a href="/stuff">stuff</a>
<a href="/words">words</a>
<ThemeIcon />
</nav>
<main>
<slot />
</main>
</body>
</html>
CSS Variables for Themes
Define your themes with CSS custom properties:
html {
--bg-color: #ecf1f5;
--text-color: black;
--menu-bg: rgba(255, 255, 255, 0.2);
}
/* System dark mode preference - works without JS */
@media (prefers-color-scheme: dark) {
html:not(.light):not(.terminal) {
--bg-color: #1a1625;
--text-color: #ffffff;
--menu-bg: rgba(0, 0, 0, 0.2);
}
}
html.dark {
--bg-color: #1a1625;
--text-color: #ffffff;
--menu-bg: rgba(0, 0, 0, 0.2);
}
html.terminal {
--bg-color: #000000;
--text-color: #00ff00;
--menu-bg: rgba(0, 255, 0, 0.1);
}
html, body {
height: 100%;
margin: 0;
font-family: "Courier New", Courier, monospace;
color: var(--text-color);
background-color: var(--bg-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
The @media (prefers-color-scheme: dark) with :not(.light):not(.terminal) is the no-JS fallback - dark mode works even if JavaScript fails.
The Key Bits
- Inline script in
<head>- Prevents flash by running before render astro:after-swaplistener - Reapplies theme after page transitionsis:inlineon scripts - Ensures they run immediately, not bundled- CSS fallback - Works without JS via
prefers-color-scheme - localStorage - Persists user choice across sessions
That’s about it - themes that persist, transitions that work, no flash.
alcun ✌️