Theme Switching & No-Flash Loading
Persisting user theme choice, avoiding FOUC, and syncing with system preference.
Design TokensIntermediate
Why
A theme switcher that flashes white on reload is broken UX. Inline-blocking CSS + a pre-hydration script sets the theme before first paint, so users never see the wrong theme.
Tokens Studio (formerly Figma Tokens) has 500k+ installs, and the W3C Design Tokens Format Module is nearing candidate recommendation. Tokens enable dark mode, multi-brand theming, and platform parity from a single source.
When
Implement no-flash loading the moment you add dark mode. Retrofitting it later means a flash on every reload until you fix it — do it right the first time.
How
1. Inline script in <head> — runs before body renders, reads localStorage, sets data-theme
2. CSS variables per theme — :root[data-theme="dark"] { --color-bg: ... }
3. Persist choice — localStorage "theme" = "light"|"dark"|"system"
4. System sync — matchMedia("(prefers-color-scheme: dark)") listener updates when "system"
5. Toggle component — accessible switch, aria-label, updates attr + storage
6. SSR — server reads cookie (not localStorage) to set initial data-theme, no flash for logged-in
7. Transition — color-scheme: light dark, plus a 150ms transition on bg/color
Example
<!-- index.html — inline script in <head>, blocks first paint -->
<head>
<script>
(function () {
var t = localStorage.getItem("theme") || "system";
var dark = t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
})();
</script>
</head>
/* tokens.css — per-theme variable sets */
:root[data-theme="light"] { --color-bg: #ffffff; --color-text: #111827; }
:root[data-theme="dark"] { --color-bg: #0f172a; --color-text: #f3f4f6; }
body { background: var(--color-bg); color: var(--color-text); transition: background 150ms, color 150ms; }
// ThemeToggle.tsx — accessible switch
function ThemeToggle() {
const [theme, setTheme] = useState(localStorage.getItem("theme") || "system");
const toggle = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.dataset.theme = next;
};
return <button onClick={toggle} aria-label="Toggle dark mode">{theme === "dark" ? "☀" : "☾"}</button>;
}
Common Problems
- ⚠FOUC on reload — theme applied after hydration, users see white flash then dark
- ⚠localStorage only — SSR users get wrong theme on first paint, no cookie fallback
- ⚠No system sync — user's OS dark mode ignored, must manually toggle every app
Best Practices
- ✓Inline the theme script in <head> before CSS — blocks first paint, zero flash
- ✓Offer 'system' as default — respects OS setting, users who set explicit choice override it
- ✓Use a cookie for SSR — server reads it and sets data-theme in the HTML, no flash for logged-in