Back to React
React
medium
mid

How would you implement dynamic theming with light and dark mode in a React app?

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.

7 min read·~15 min to think through

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:

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

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

html
<!-- 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

js
// 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.

tsx
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> with prefers-color-scheme media 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.

Related questions