Build a Dark mode + theming
CSS custom properties for theme tokens, toggled via a data-attribute/class on <html>. Theme state in Context, persisted to localStorage, defaulting to prefers-color-scheme. Critical detail: apply the theme before first paint (inline script) to avoid a flash of the wrong theme (FOUC).
Dark mode is a theming system: design tokens as CSS variables, a theme switch, persistence, and — the detail interviewers look for — no flash of the wrong theme.
1. CSS custom properties as theme tokens
Define semantic tokens, swapped by a selector on the root:
:root {
--bg: #ffffff;
--text: #1a1a1a;
--surface: #f5f5f5;
}
:root[data-theme="dark"] {
--bg: #1a1a1a;
--text: #f5f5f5;
--surface: #2a2a2a;
}
body { background: var(--bg); color: var(--text); }Components consume var(--bg) etc. — switching the theme is just changing one attribute on <html>; every variable cascades. Use semantic token names (--surface, --text-muted), not --white.
2. Theme state — Context + persistence + system preference
function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() =>
localStorage.getItem("theme") ||
(matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
);
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem("theme", theme);
}, [theme]);
// ...provide { theme, setTheme }
}Priority: user's saved choice → system prefers-color-scheme → light default.
3. The critical detail: prevent the flash (FOUC)
If the theme is applied in a useEffect (after React mounts), the page paints in the default theme first, then flips — a jarring flash. The fix: a tiny blocking inline script in <head> that sets data-theme from localStorage/system preference before the browser paints:
<script>
document.documentElement.dataset.theme =
localStorage.getItem("theme") ||
(matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
</script>(With SSR this is more involved — you can't know the theme on the server; the inline script runs before hydration.)
4. Other considerations
- Three-way: light / dark / system — "system" follows
prefers-color-schemelive (listen to the media query change event). color-schemeCSS property — so native form controls/scrollbars match.- Smooth transition on the toggle (transition
background-color/color) — but disable it on initial load. - Accessibility — maintain contrast ratios in both themes; the toggle is a labeled, accessible control.
- Don't theme via JS-injected inline styles — CSS variables are the clean approach.
The framing
"Theme tokens as CSS custom properties on :root, swapped by a data-theme attribute on <html> so one change cascades everywhere. Theme state in Context, persisted to localStorage, defaulting to prefers-color-scheme. The detail that matters most is preventing the flash of wrong theme — if I set the theme in a useEffect the page paints light then flips, so I put a tiny blocking inline script in the <head> that applies data-theme before first paint. Then: a light/dark/system option, the color-scheme property for native controls, and verified contrast in both themes."
Follow-up questions
- •Why does applying the theme in useEffect cause a flash?
- •How do you default to the user's system preference?
- •What does the CSS color-scheme property do?
- •How would you handle the theme flash with SSR?
Common mistakes
- •Applying the theme only in useEffect — flash of wrong theme on load.
- •Hardcoding colors instead of using CSS custom properties.
- •Non-semantic token names (--white) that don't make sense in dark mode.
- •Not respecting prefers-color-scheme.
- •Poor contrast in one of the themes.
Performance considerations
- •CSS variables switch with zero JS re-render cost — the browser just re-resolves var(). The blocking inline script is tiny and runs once; that's an acceptable trade for eliminating the flash.
Edge cases
- •First visit with no saved preference.
- •System preference changes while the app is open.
- •SSR — theme unknown on the server.
- •Native controls (inputs, scrollbars) not matching the theme.
- •Transition flash on initial load.
Real-world examples
- •next-themes handling the FOUC inline script and system preference.
- •Design systems exposing semantic color tokens swapped per theme.