Theming with Custom Properties
Build light/dark and brand themes with CSS variables — token layers, data-theme switches, component contracts, and FOUC avoidance.
- css
- theming
- custom-properties
Theming is a token problem: components consume semantic variables (--surface, --text, --danger), and themes only reassign those tokens. Custom properties make that assignment cascade with the DOM — no preprocessor rebuild for a dark mode toggle.
Docs: MDN custom properties, color-scheme.
Semantic tokens over raw brand hex
:root {
color-scheme: light;
/* palette */
--blue-600: #0b6bcb;
--blue-300: #6cb6ff;
--gray-0: #ffffff;
--gray-12: #14212b;
/* semantic */
--surface: var(--gray-0);
--text: var(--gray-12);
--brand: var(--blue-600);
--border: color-mix(in srgb, var(--text) 12%, transparent);
--focus: var(--brand);
--radius: 0.5rem;
--space-2: 0.5rem;
}
[data-theme="dark"] {
color-scheme: dark;
--surface: #0f1419;
--text: #e7ecf1;
--brand: var(--blue-300);
--border: color-mix(in srgb, var(--text) 18%, transparent);
}
Components should reference semantic names:
.card {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3, 1rem);
}
.button--primary {
background: var(--brand);
color: var(--on-brand, white);
}
If a component hard-codes #0b6bcb, theming is already broken.
Theme scopes
<html data-theme="dark">
<body>
<main>…</main>
<aside data-theme="light" class="preview">
<!-- nested theme island -->
</aside>
</body>
</html>
Variables inherit. Nested [data-theme] redefines tokens for a subtree — useful for email previews or theme pickers.
Brand white-label
:root[data-brand="acme"] {
--brand: #c45c26;
--radius: 0.25rem;
}
:root[data-brand="globex"] {
--brand: #0b6bcb;
--radius: 0.75rem;
}
Ship one CSS bundle; swap attributes per tenant.
Avoid FOUC
<head>
<script>
(() => {
try {
const t = localStorage.getItem("theme");
if (t === "light" || t === "dark") {
document.documentElement.dataset.theme = t;
}
} catch {}
})();
</script>
<link rel="stylesheet" href="/app.css" />
</head>
Pair with prefers-color-scheme when no stored preference exists (in CSS defaults).
Component contracts
Document which tokens a component requires:
| Token | Role |
|---|---|
--surface |
Background |
--text |
Foreground |
--border |
Dividers |
--brand |
Accent actions |
--danger |
Errors |
Optional local overrides:
.button {
--_bg: var(--brand);
background: var(--_bg);
}
.button--ghost {
--_bg: transparent;
color: var(--brand);
border: 1px solid var(--border);
}
Interview out-loud
“I theme with semantic CSS variables on :root and override them under data-theme or brand attributes. Components never hard-code palette colors. I set color-scheme for native controls, apply the theme early from localStorage to avoid flashes, and allow nested theme scopes when needed.”
Footguns
- Mixing semantic and raw palette usage inconsistently.
- Animating large sets of variables every frame.
- Incomplete dark tokens (one forgotten white modal).
- Contrast regressions in one theme only.
- Storing theme only in React state without SSR/early paint sync.
Multi-brand + multi-mode
:root,
:root[data-brand="acme"] {
--brand: #0b6bcb;
}
:root[data-brand="globex"] {
--brand: #c45c26;
}
:root[data-theme="dark"] {
--surface: #0f1419;
--text: #e7ecf1;
--brand: color-mix(in oklch, var(--brand) 70%, white);
}
Order matters: brand sets the raw accent; theme may re-tint it for dark surfaces. Document the cascade for other engineers. Snapshot tests per brand×theme pair catch missing tokens quickly.
Runtime preview without flash
Theme pickers can set CSS variables on a preview root:
.theme-preview {
--surface: #0f1419;
--text: #e7ecf1;
--brand: #6cb6ff;
background: var(--surface);
color: var(--text);
}
Isolating preview tokens prevents the whole app from flashing while the user scrubs options. Commit applies tokens to document.documentElement and localStorage together.
Related
Further reading
Related guides
- CSS Custom PropertiesCSS variables that cascade and inherit — define tokens, theme at runtime, fall back safely, and avoid the invalid-at-computed-value trap.
- prefers-color-schemeRespect light/dark OS preferences with prefers-color-scheme, token overrides, color-scheme, and manual theme toggles that stay in sync.
- Accent Color and Form ControlsUse accent-color to theme native checkboxes, radios, ranges, and progress — what it paints, what it skips, and when to custom-style instead.
- Auto-fit vs Auto-fillauto-fit collapses empty tracks; auto-fill keeps them. Use both with minmax for responsive card grids without media-query soup.
- BEM Naming MethodologyBlock, Element, Modifier naming for CSS — how it limits specificity wars, what to name, and when utility or CSS modules beat strict BEM.