How would you design and implement a theme switcher (light/dark mode) 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.
Tokens via CSS variables
: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:
<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
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:
- Inline script + data-theme (above) — server renders neutral, script flips before paint.
- Cookie-based pref — server reads cookie, renders with correct
data-themefrom the start. color-schememeta —<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
// 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:
[data-theme="brand-a"] { --primary: ...; }
[data-theme="brand-b"] { --primary: ...; }One mechanism handles theming + branding.
Accessibility
- Respect
prefers-color-schemeby 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.