Back to React
React
easy
mid

How would you build dark mode and theming support in a React app?

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).

4 min read·~20 min to think through

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:

css
: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

jsx
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:

html
<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-scheme live (listen to the media query change event).
  • color-scheme CSS 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.

Senior engineer discussion

Seniors use CSS variables with semantic tokens, persist + default to system preference, and crucially solve the FOUC with a pre-paint inline script — plus light/dark/system, color-scheme, and contrast in both themes.

Related questions