Theming
Date: 2026-08-16
Swapping the values behind semantic token names while every component stays untouched. If a theme requires changing a component, the semantic layer isn’t doing its job.
Theming is providing more than one set of values for the same set of semantic token names — light and dark, brand variants, white-label tenants — so that appearance changes without any component knowing.
COMPONENT background: var(--surface)
▲
never changes
LIGHT THEME --surface: #ffffff
DARK THEME --surface: #16181c
BRAND B --surface: #faf7f2
The component references a role. The theme supplies the value. That indirection is the entire mechanism — Design Tokens.
The implementation
CSS custom properties, cascading from the root:
:root {
--surface: #ffffff;
--text: #16181c;
--action: #1d4ed8;
}
:root[data-theme="dark"] {
--surface: #16181c;
--text: #f4f4f5;
--action: #60a5fa;
}Scoping to a subtree works identically, which is what makes an inverted section or a per-tenant area possible without a second stylesheet:
<footer data-theme="dark">
<!-- everything inside re-themes -->
</footer>Three states, not two
The mistake almost everyone makes. A theme preference has three values, and only two of them stamp an attribute:
"light" explicit → data-theme="light"
"dark" explicit → data-theme="dark"
"system" the default → NOTHING is stamped
only prefers-color-scheme separates
light from dark
Which means the correct structure is three layers, in this order:
/* 1. full light palette on bare :root */
:root { --surface: #fff; … }
/* 2. dark under the media query,
guarded so an explicit light wins */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--surface: #16181c; …
}
}
/* 3. dark again for the explicit toggle */
:root[data-theme="dark"] {
--surface: #16181c; …
}Never define a colour only inside a media query or a [data-theme] block. A token that exists in one branch and not the other produces a variable resolving to nothing, which fails silently and inconsistently.
Avoiding the flash
A theme applied by JavaScript after paint shows the wrong theme first.
HTML parsed → CSS applied (light) → PAINT
↓ flash
JS runs → reads preference → sets dark
The fix is a small blocking inline script in <head> that reads the stored preference and sets the attribute before first paint. It’s one of very few places a render-blocking script is correct — The Critical Rendering Path.
What themes should and shouldn’t change
SAFE TO THEME
colour
shadow (dark themes need less)
border colour and sometimes weight
DANGEROUS TO THEME
spacing → breaks layout
type scale → breaks rhythm
radius → usually fine, rarely needed
component structure → not a theme
A theme that changes spacing is a different design system wearing a theme’s name. Keep the structural tokens fixed and vary the surface ones, or every layout must be tested per theme.
Dark mode specifics
Not an inversion — Colour Systems:
- Lower chroma on dark surfaces. Saturated colours vibrate against dark backgrounds
- Lighter accents. Contrast reverses, so the action colour goes up the scale rather than down
- Shadows barely work. Elevation on dark surfaces is communicated by making things lighter, not by casting shadow
- Avoid pure black and pure white. Maximum contrast causes halation and eye strain
Where it goes wrong
- Parallel stylesheets. Two full sets of component CSS is double the maintenance and guaranteed drift
- Hard-coded values surviving in components. One
color: #333and that component ignores every theme. Lint for literal colours in component files - Images and illustrations. SVGs using
currentColortheme for free; raster assets need a second version, and this is usually forgotten until launch - Testing only one theme. Contrast, focus rings and disabled states all need checking per theme — WCAG