Skip to content
Back to Design Hub

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

FAQ