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.
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
: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:
.card {
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
}Switch theme:
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:
- Primitive tokens — raw values:
--gray-50,--gray-900,--blue-500. - 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.
: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:
<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:
function resolveTheme(pref) {
if (pref === 'system' || !pref) {
return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return pref;
}Listen for system changes:
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
:rootand 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 components —
color: #111makes 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-schemeby 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
: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.