Dynamic theming — light/dark mode
Use CSS custom properties for color tokens; toggle a `class="dark"` or `data-theme` attribute on `<html>` to flip them. Read user preference from localStorage, fall back to `prefers-color-scheme`. Set the class **before first paint** (inline script in `<head>` for SSR) to avoid a light→dark flash. Tailwind: `darkMode: "class"`. Persist and broadcast changes across tabs.
Dark mode looks trivial. The implementation has three traps: flash of wrong theme, system preference, cross-tab consistency.
The token system
Define semantic tokens (not raw colors) in CSS variables:
:root {
--bg: #ffffff;
--fg: #111111;
--muted: #6b7280;
--accent: #3b82f6;
--border: #e5e7eb;
}
[data-theme="dark"] {
--bg: #0a0a0a;
--fg: #f5f5f5;
--muted: #9ca3af;
--accent: #60a5fa;
--border: #1f2937;
}Components reference var(--bg), never #fff. Flip the theme by setting data-theme (or class="dark") on <html>.
Why CSS variables. Changing them re-paints the page instantly without re-rendering React. No JS prop drilling.
Toggling
type Theme = "light" | "dark" | "system";
function useTheme() {
const [theme, setTheme] = useState<Theme>(() =>
(localStorage.getItem("theme") as Theme) ?? "system"
);
useEffect(() => {
const resolved = theme === "system"
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: theme;
document.documentElement.dataset.theme = resolved;
localStorage.setItem("theme", theme);
}, [theme]);
// react to OS-level changes when in "system" mode
useEffect(() => {
if (theme !== "system") return;
const mq = matchMedia("(prefers-color-scheme: dark)");
const onChange = () => {
document.documentElement.dataset.theme = mq.matches ? "dark" : "light";
};
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [theme]);
return [theme, setTheme] as const;
}The flash problem (FOUC)
Without precautions: HTML loads white → React mounts → reads localStorage → flips to dark. Users see ~200ms of white.
The fix: inline script in <head> that runs before paint.
<!-- in <head>, BEFORE <body> -->
<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>Yes, it's an inline script, and yes, it's worth it. CSP nonce or hash if your policy requires.
Next.js's next-themes library does exactly this — most apps in 2026 just use it.
Tailwind
// tailwind.config.js
module.exports = {
darkMode: ["class", '[data-theme="dark"]'],
...
};Then write bg-white dark:bg-neutral-900. Tailwind generates the variants gated on data-theme="dark".
For full dark-mode token system, define tokens in CSS variables and reference them in the Tailwind theme config — e.g., colors: { bg: "var(--bg)" } — so utilities just work.
Cross-tab
User toggles dark mode in tab A; tab B should follow.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === "theme") setTheme(e.newValue as Theme);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);storage event fires in OTHER tabs (not the one that set it). BroadcastChannel is the more modern alternative.
Three-way: light / dark / system
UI should expose three states, not a toggle:
- System — follow OS.
- Light — force light.
- Dark — force dark.
Save the user's choice (the literal "system"), not the resolved value. Otherwise switching OS doesn't propagate.
Beyond colors
Real "theming" includes:
- Density (compact vs comfortable spacing).
- Font size (accessibility).
- High-contrast mode (separate from dark).
- Reduced motion (
prefers-reduced-motion— disable transitions). - Brand themes (multi-tenant SaaS with white-label).
Use the same CSS-variable approach. Multiple data attributes can coexist: data-theme="dark" data-density="compact".
Accessibility
- Test contrast in BOTH themes. Dark mode has different WCAG implications; pure black on pure white is harsh; pure white on pure black causes "smearing" for some users. Use near-black/near-white.
- Respect
prefers-reduced-motion— disable theme-switch transitions. - Provide a UI toggle; don't rely on OS preference alone (some users want app-specific theming).
Images and SVGs
<picture>withprefers-color-schememedia query for raster.- Inline SVG with
currentColor— flips automatically with text color. - Logos: provide a dark-variant; switch via CSS rule on
[data-theme="dark"].
Senior framing
The interviewer wants: (1) CSS variables for tokens, (2) class/attribute on <html> for the switch, (3) inline-script-in-head to avoid FOUC, (4) three-way state (light/dark/system) with OS-listener, (5) cross-tab sync, (6) Tailwind / CSS-in-JS integration, (7) extensibility to other "themes" (density, brand).
The "I add a dark class with useState" answer is junior. The full architecture above is senior.
Follow-up questions
- •Why does the inline-script-in-head matter for FOUC prevention?
- •How would you handle multi-brand theming in a SaaS app?
- •Why store 'system' instead of the resolved value?
- •What's the cross-tab synchronization story?
Common mistakes
- •Flash of light theme on first paint.
- •Toggling theme only on the parent component instead of `<html>`.
- •Storing the resolved theme instead of the user's choice.
- •Hardcoding hex colors instead of using token variables.
Performance considerations
- •Toggling CSS variables on `<html>` is one repaint, no re-render.
- •Avoid running JS to compute colors per render; let CSS handle it.
Edge cases
- •Print stylesheet — typically force light.
- •Embedded iframes that should inherit theme — postMessage on change.
- •Server-rendered output with no prior visit — show OS preference via the inline script.
Real-world examples
- •next-themes is the canonical Next.js dark-mode library.
- •GitHub, Linear, Vercel — all use the inline-script pattern + CSS variables.