Back to System Design
System Design
medium
mid

How would you implement dynamic theming such as light and dark mode in a large web application without performance issues?

Use CSS custom properties (variables) on :root, swap via a data-theme attribute. Apply theme on the server (or via an inline script BEFORE first paint) using a cookie/localStorage value to avoid flash. Provide system-default with prefers-color-scheme media query. Keep variable count manageable, organize semantic tokens (color-bg, color-text) over raw colors. Avoid JS-driven style mutation for performance.

8 min read·~15 min to think through

Theming a large app is a system design problem, not a CSS trick. The goal: instant theme switch, no flash-of-wrong-theme on first paint, easy to add new themes, performant at scale.

Architecture: CSS custom properties + data attribute

css
:root, [data-theme="light"] {
  --color-bg: #fff;
  --color-text: #111;
  --color-primary: #2563eb;
  --color-border: #e5e7eb;
}

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-text: #f1f5f9;
  --color-primary: #60a5fa;
  --color-border: #334155;
}

Components use variables, never raw colors:

css
.card {
  background: var(--color-bg);
  color: var(--color-text);
  border: 1px solid var(--color-border);
}

Switch theme:

js
document.documentElement.setAttribute('data-theme', 'dark');

Variable changes cascade to every using element with zero JS cost.

Token layers (the scale part)

Don't define every color twice (light/dark). Define a two-layer token system:

  1. Primitive tokens — raw values: --gray-50, --gray-900, --blue-500.
  2. Semantic tokens — meaning: --color-bg = var(--gray-50) in light mode, var(--gray-900) in dark.

Components consume semantic tokens only. Adding a new theme = remap semantic → primitive once. Adding a new component = no theme change needed.

css
:root {
  /* primitives */
  --gray-50:  #fafafa;
  --gray-900: #18181b;
  --blue-500: #3b82f6;
  --blue-300: #93c5fd;
}
[data-theme="light"] {
  --color-bg:      var(--gray-50);
  --color-text:    var(--gray-900);
  --color-primary: var(--blue-500);
}
[data-theme="dark"] {
  --color-bg:      var(--gray-900);
  --color-text:    var(--gray-50);
  --color-primary: var(--blue-300);
}

Flash-of-wrong-theme (FOUC)

The killer UX bug: page loads with light theme, then snaps to dark.

Fix: set the theme before any CSS paints.

Server-rendered

Read the cookie on the server, render <html data-theme="${theme}">. Zero flash.

Client-rendered (Vite/CRA)

Inline a script in <head> that runs before CSS:

html
<script>
  (function () {
    var t = localStorage.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', t);
  })();
</script>

This blocks paint by a couple of ms — fine, and it's the only way to avoid flash with client-side rendering.

System default + override

Three states usually: light, dark, system. Persist user choice; otherwise follow OS:

js
function resolveTheme(pref) {
  if (pref === 'system' || !pref) {
    return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }
  return pref;
}

Listen for system changes:

js
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
  if (getPref() === 'system') applyTheme(resolveTheme('system'));
});

Persistence

  • localStorage: simple, but only readable client-side → can't do SSR pre-render correctly.
  • Cookie: readable on server, perfect for SSR.
  • For Next.js: cookie + read in a Server Component / middleware.

Performance properties

  • CSS variable change = paint, not layout (in modern browsers). Fast.
  • No style recalc cascade explosion because the variable lives on :root and consumers re-resolve on the next paint.
  • No JS class toggle on every component: just one attribute on <html>.

Things to avoid

  • Per-component CSS-in-JS theming that re-renders the whole tree on toggle — slow on large apps. Prefer CSS variables.
  • Hard-coded colors in componentscolor: #111 makes the next theme migration painful.
  • JS-only theming (style.background = …) — defeats CSS caching and is slow.
  • Recomputing computed colors in JS on each render — precompute via tokens.

Accessibility

  • Respect prefers-color-scheme by default.
  • Respect prefers-reduced-motion (don't animate the theme transition for users who opt out).
  • Verify contrast ratios in both themes (WCAG AA = 4.5:1 for text). Easy to ship a dark theme that fails contrast on muted text.

Bonus: smooth transition

css
:root { transition: background-color 0.2s, color 0.2s; }
@media (prefers-reduced-motion: reduce) { :root { transition: none; } }

Apply only to a small set of properties — animating every property on every element will jank on large pages.

Follow-up questions

  • How do you prevent FOUC in a Next.js app?
  • Why use semantic tokens over primitive tokens directly?
  • What's the perf cost of CSS variable changes vs class swaps?
  • How do you test contrast for both themes in CI?

Common mistakes

  • Reading theme from localStorage in useEffect — flash because first paint is wrong.
  • Setting theme via CSS-in-JS prop drill on every component — slow re-renders.
  • Hard-coded colors in components, then having to migrate them all at theme time.
  • Animating every CSS property on theme change — jank.
  • Forgetting prefers-color-scheme system default.
  • Not testing contrast in the dark theme — muted gray text often fails AA.

Performance considerations

  • CSS variables on a single root attribute cost effectively nothing per theme switch — one paint, no JS re-render. The wrong approach (CSS-in-JS re-emitting all styles on toggle) can take 100ms+ on large trees. Inline pre-paint script costs a few ms but is mandatory for no-flash CSR. SSR with cookie is the cleanest path.

Edge cases

  • iOS Safari respects prefers-color-scheme on the system level but PWA window may need explicit theme-color meta.
  • <meta name='theme-color'> for browser chrome (status bar) — set per theme.
  • User scripts / extensions that override colors may conflict.
  • Print stylesheet usually wants light theme regardless.
  • Forced colors mode (Windows high contrast) — respect with prefers-contrast and forced-colors media queries.

Real-world examples

  • GitHub uses data-color-mode + data-light-theme + data-dark-theme attributes with primer-css tokens.
  • Tailwind's dark: variant compiles to .dark .class { … } class-based theming.
  • Material UI v5 uses CSS variables in its 'cssVariables' mode for SSR-friendly theming.
  • Stripe Dashboard and Vercel UI both ship inline pre-paint scripts for theme.

Senior engineer discussion

Seniors design theming as a token system, not a switch. They separate primitive from semantic tokens, account for the SSR/CSR flash, respect system preferences and reduced motion, and benchmark theme switch on large pages. They also catch the contrast trap — light theme passes AA; dark theme silently doesn't — and bake that check into CI.

Related questions