Dark themes bolted on after the fact leave a recognisable trail: a variable here, an !important there, a duplicated dark.css file that drifts away from the light theme sprint after sprint. The problem is not colour, it is the absence of structure. Design tokens expressed as CSS custom properties supply that structure, with no dependency and no JavaScript layer.
A token is not a colour
The most common confusion is treating a raw value (#1e40af) as a token. A design token is a named intent: “the main text colour”, not “a dark blue”. Hence an organisation in two layers. The primitive layer holds raw values, never used directly by components. The semantic layer holds intents, and is the only one selectors touch.
:root {
/* Layer 1: primitives (never used as-is) */
--gray-900: #111418;
--gray-50: #f7f8fa;
--blue-500: #2563eb;
--blue-300: #93c5fd;
/* Layer 2: semantic (the component API) */
--color-bg: var(--gray-50);
--color-text: var(--gray-900);
--color-accent: var(--blue-500);
}The maintenance benefit is immediate: a change of brand hue is handled in the primitives, a contrast issue raised in an accessibility audit is handled in the semantic layer. Components do not move — they consume var(--color-text).
A component that references a raw colour is technical debt in disguise. A component that references a semantic token is a contract.
Dark mode as a redefinition
Once that separation is in place, dark mode becomes an unremarkable operation — which is exactly the goal. Nothing is duplicated: only the semantic layer is redefined. Primitives stay, components stay, only the mapping changes.
1. Respect the system preference
The starting point is prefers-color-scheme, which reflects the preference expressed by the user at operating-system level. Ignoring it means overriding an explicit setting.
@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--gray-900);
--color-text: var(--gray-50);
--color-accent: var(--blue-300);
}
}2. Allow an explicit choice
An attribute set on the root element overrides the system preference when the user switches manually. Rule order determines priority: the explicit choice must win in both directions.
:root[data-theme='dark'] { /* same redefinitions */ }
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { /* same redefinitions */ }
}3. Avoid the flash on load
The only genuinely necessary line of JavaScript is a blocking script in the <head> that applies the stored theme before the first paint. Any other approach produces a light-theme flash on pages loaded in dark mode.
<script>
try {
var t = localStorage.getItem('theme');
if (t) document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>color-scheme: light or dark on :root is not a detail. It tells the browser to adapt form controls, scrollbars and native fields. Without it, a dark theme is always given away by its <select> elements.What the method prevents
| Common symptom | Cause | Token-based answer |
|---|---|---|
A dark.css file that drifts | Duplicated rules | One stylesheet, semantic redefinition |
Accumulating !important | Specificity conflicts | Components read a single variable |
| Insufficient contrast in dark mode | Colours chosen case by case | Centralised fix on one token |
| Light flash on load | Theme applied after paint | Blocking script in the <head> |
What to take away
A maintainable light/dark theme requires neither a framework nor a JavaScript layer: two token layers, a semantic redefinition, and a three-line script against the initial flash. Most of the work is an architectural decision taken before the first style rule is written.
This is the method I have applied on client projects for several years, and this site’s theme is a direct application of it. The rule I no longer bend: no component references a primitive. The day that rule goes, the debt is back within six months. — Simon Janvier
