Swapping theme tokens at runtime with plain CSS
- CSS
- design tokens
- dark mode
- Next.js
A short, practical pattern: keep brand colors as CSS variables on :root, override them under .dark, and let utilities consume var(--palette-*) so one toggle updates the whole UI.
On this page
When you surface palette colors through a single layer of primitives, the theme switch stops being a rebuild problem and becomes a class on <html>.
This mirrors how this site works today: Tailwind maps brand slots to var(--palette-*), and .dark swaps those primitives while layout and prose stay the same.
Two layers
- Primitives — values that actually change between themes, e.g.
--palette-cream,--palette-nocturne. - Semantics — components read semantics (
background,text-ink) that resolve to those primitives.
Minimal :root / .dark contract
:root {
--palette-cream: #eeeae3;
--palette-nocturne: #2c2e28;
}
.dark {
--palette-cream: #131412;
--palette-nocturne: #e6e4dc;
}
Utility output stays background-color: var(--palette-cream) instead of baked-in hex, so it tracks runtime overrides.
Toggle from React
function toggleDarkMode() {
const root = document.documentElement;
const next = !root.classList.contains("dark");
root.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
}
Pair that with a tiny inline script in <head> so the first paint respects localStorage and avoids a flash.
Fenced TypeScript (async loader)
export async function applyThemePreference() {
const stored = localStorage.getItem("theme");
if (stored === "dark" || stored === "light") {
document.documentElement.classList.toggle("dark", stored === "dark");
return;
}
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", prefersDark);
}
Shell check
# Optional: curl the page and assert the blocking script exists
curl -sSf "http://localhost:3000" | rg "localStorage.getItem\\('theme'\\)" -q
echo "Theme bootstrap present"
Why it scales
- One observer (
MutationObserverorlocalStorage) can drive the toggle control without fighting hydration. - Markdown / MDX body, cards, and navigation all inherit the same tokens, so dark mode does not “half apply”.
If you scope a band (like a footer) differently, reset --palette-* on that subtree instead of reintroducing one-off hex values in components.