QuestionsSystem Design

Design light/dark theme switching

ThemingEasySystem Design

Design theme switching (light/dark/system). Apply RADIO; cover FOUC, persistence, and prefers-color-scheme.

What it tests

Design-token strategy, avoiding the flash of wrong theme, and honoring the OS preference.

Approach & answer

Requirements: light/dark (and 'system') themes, no flash of the wrong theme on load (FOUC), persistence across visits, respect for the OS preference, and runtime switching. Architecture: express all colors as CSS custom properties (design tokens) scoped under a root attribute — [data-theme='dark'] { --bg: …; --fg: … } — so switching is a single attribute flip, not a re-render of styled components. Data model: a resolved theme ('light'|'dark') plus a mode ('light'|'dark'|'system'); persist the mode in localStorage. Interface: setTheme('dark'); a toggle in the UI. Optimizations: the critical trick is a tiny BLOCKING inline script in <head> that reads localStorage and sets data-theme BEFORE first paint — otherwise the page paints the default theme, then flips (FOUC). Honor prefers-color-scheme via matchMedia when the mode is 'system', and listen for changes so the app follows the OS live. Set color-scheme on the root so native controls (scrollbars, form widgets) match. Why CSS variables over a JS theme object: the browser recomputes cascaded values on one attribute change with zero React re-render, so theming is instant and also works for non-React CSS. Accessibility: keep both themes above WCAG contrast, and don't override the user's OS setting without an explicit choice — persist an explicit choice, but fall back to system when they haven't chosen.

Use this technique when

Any dark-mode / white-label theming; deciding tokens vs a JS theme object; killing the theme flash on load.

Code

// 1) Blocking script in <head> — runs before first paint, avoids FOUC:
(function () {
  var saved = localStorage.getItem('theme');            // 'light' | 'dark' | null
  var sys = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  document.documentElement.dataset.theme = saved || sys;
})();

// 2) Runtime toggle:
function setTheme(mode) {                                 // 'light' | 'dark'
  document.documentElement.dataset.theme = mode;
  localStorage.setItem('theme', mode);
}

// 3) Follow the OS live when the user hasn't chosen:
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('theme'))
    document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
});

/* CSS:  :root { --bg:#fff; --fg:#111 }
         [data-theme="dark"] { --bg:#111; --fg:#eee; color-scheme:dark } */

References