Back to React
React
hard
mid

How would you design and implement a light and dark theme switcher in a scalable React app?

Tokens as CSS variables under `[data-theme]` or a class on `<html>`. Resolve theme on mount from localStorage or `prefers-color-scheme`. Apply before first paint via inline `<script>` to avoid flash. Provide a React context for components to read the current theme. Persist user choice. Respect `prefers-color-scheme: dark` as default. Support system / light / dark trichotomy.

4 min read·~15 min to think through

Tokens via CSS variables

css
:root {
  --bg: 255 255 255;
  --fg: 17 17 17;
  --accent: 96 165 250;
}
[data-theme="dark"] {
  --bg: 17 17 17;
  --fg: 245 245 245;
  --accent: 59 130 246;
}
body {
  background: rgb(var(--bg));
  color: rgb(var(--fg));
}

Components reference tokens, not specific colors. Switching is just changing the attribute.

Apply before paint (no flash)

In <head>, before React boots:

html
<script>
  (function () {
    try {
      var saved = localStorage.getItem("theme") || "system";
      var dark = saved === "dark" ||
        (saved === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
      document.documentElement.dataset.theme = dark ? "dark" : "light";
    } catch (e) {}
  })();
</script>

Synchronous; runs before first paint; no flash of wrong theme.

React context

tsx
type ThemePref = "light" | "dark" | "system";
const ThemeCtx = createContext<{
  theme: "light" | "dark";
  pref: ThemePref;
  setPref: (p: ThemePref) => void;
}>(null!);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [pref, setPrefState] = useState<ThemePref>(
    () => (typeof window !== "undefined" && (localStorage.getItem("theme") as ThemePref)) || "system",
  );

  const resolve = useCallback((p: ThemePref) =>
    p === "system"
      ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
      : p,
  []);

  const [theme, setTheme] = useState<"light" | "dark">(() => resolve(pref));

  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  // React to OS pref changes when in "system" mode
  useEffect(() => {
    if (pref !== "system") return;
    const mq = matchMedia("(prefers-color-scheme: dark)");
    const handler = () => setTheme(mq.matches ? "dark" : "light");
    mq.addEventListener("change", handler);
    return () => mq.removeEventListener("change", handler);
  }, [pref]);

  const setPref = (p: ThemePref) => {
    setPrefState(p);
    localStorage.setItem("theme", p);
    setTheme(resolve(p));
  };

  return <ThemeCtx.Provider value={{ theme, pref, setPref }}>{children}</ThemeCtx.Provider>;
}

export const useTheme = () => useContext(ThemeCtx);

Light / dark / system trichotomy

Most users want OS-following. "Light" and "dark" are explicit overrides. The toggle is usually a select, not a binary switch.

SSR

Server doesn't know the user's pref. Options:

  1. Inline script + data-theme (above) — server renders neutral, script flips before paint.
  2. Cookie-based pref — server reads cookie, renders with correct data-theme from the start.
  3. color-scheme meta<meta name="color-scheme" content="light dark"> hints browser scrollbars/form controls.

For Next.js, libraries like next-themes handle this end-to-end.

Tailwind dark mode

js
// tailwind.config.js
module.exports = { darkMode: ["class", "[data-theme=dark]"] };

Then <div class="bg-white dark:bg-gray-900">. Matches the data-theme attribute approach.

Multi-brand theming

CSS variables generalize beyond light/dark:

css
[data-theme="brand-a"] { --primary: ...; }
[data-theme="brand-b"] { --primary: ...; }

One mechanism handles theming + branding.

Accessibility

  • Respect prefers-color-scheme by default.
  • Toggle has clear labels and ARIA.
  • Contrast ratios meet AA in both themes (test).
  • Don't auto-flip based on time of day — surprising.

Performance

  • CSS variable change is composite-only (no layout). Theme swap is essentially free.
  • No JS reflow needed.

Common mistakes

  • No FOIT/flash mitigation — user sees light flash on dark-mode reload.
  • Hard-coded colors in components instead of tokens.
  • Theme state in a component re-rendering the world on toggle.
  • Forgetting to remove the OS listener in cleanup.

Interview framing

"CSS variables as tokens scoped under [data-theme=dark] (or a class). Apply before React boots via an inline synchronous script in <head> that reads localStorage and sets data-theme — eliminates the flash. React context exposes { theme, pref, setPref } with a trichotomy: light / dark / system. matchMedia listener for OS changes when in 'system'. Persist user choice in localStorage. For SSR, either cookie-based pref on the server or the inline script approach. Tailwind dark mode and multi-brand theming both fit the same data-theme attribute approach. Theme swap is composite-only — free perf-wise."

Follow-up questions

  • How do you avoid theme flash on SSR?
  • Why a trichotomy instead of just light/dark?
  • How would you extend this for multi-brand?

Common mistakes

  • Flash of wrong theme.
  • Hard-coded colors instead of tokens.
  • No system option.
  • Missing cleanup on matchMedia listener.

Performance considerations

  • CSS var swap is composite-only; no layout work. Inline script avoids flash.

Edge cases

  • System pref changes mid-session.
  • SSR + cookie based.
  • Embedded iframes inheriting parent theme.

Real-world examples

  • next-themes, Radix Themes, GitHub Primer, Linear's theme settings.

Senior engineer discussion

Seniors use tokens via CSS variables (not Tailwind classes hard-coded per theme), handle the trichotomy correctly, and design the inline-script trick to avoid flash on SSR.

Related questions