Dark mode design tokens that do not fall apart
Dark mode breaks when colours are named by what they look like. How to name tokens by job, build the surface ramp, and flip themes without touching components.
Most dark modes are built by inverting a light one, and most of them look it: grey text on grey cards, borders that vanish, a brand colour that glowed on white and now buzzes. The fix is not a better palette. It is naming colours by what they do, so the dark theme is a second set of answers to the same questions.
Two layers: the ramp and the jobs
Write the raw colours once, as a ramp, at the top of the file. Then write the semantic tokens that components are allowed to use. Components never see the ramp.
:root {
/* ramp: what colours exist */
--ink-950: oklch(14% 0.02 285);
--ink-900: oklch(18% 0.025 285);
--ink-800: oklch(24% 0.03 285);
--violet-400: oklch(68% 0.19 295);
/* jobs: what components may use */
--surface: var(--ink-950);
--surface-raised: var(--ink-900);
--line: oklch(100% 0 0 / 0.08);
--text: oklch(96% 0.01 285);
--text-muted: oklch(72% 0.02 285);
--accent: var(--violet-400);
}A light theme redefines the jobs, not the ramp. A card written as background: var(--surface-raised) is correct in both, and nobody has to remember which grey was the dark one.
The surface ramp is the whole trick
In light mode, depth comes from shadow. In dark mode, shadows barely show, so depth comes from lightness: each raised layer is a step lighter than the one under it. Three or four surface steps cover almost every interface: the page, a card, something on a card, and an overlay.
- Keep the steps small and even. A card that is much lighter than the page reads as a hole in it.
- Tint the darks with your brand hue. Neutral grey on a coloured brand looks unfinished.
- Draw borders as white at low opacity, not as a mid grey. A translucent line sits correctly on every surface step; a fixed grey is right on one of them.
Text needs its own ramp
Two or three text tokens, each tested for contrast against every surface it can land on: primary, muted and faint. Faint is for timestamps and fine print, and it is the one that fails first. Check it against the lightest surface, not the darkest.
Accents change meaning in the dark
A saturated colour that sits quietly on white gets louder on black. The usual fix is a slightly lighter, slightly less saturated step for dark mode, and fewer places that use it. Decide which one element per screen gets the accent, and say so in the token file as a comment, because that rule is what keeps a dark UI from looking like a slot machine. We wrote more on that in the one accent colour rule.
Test it with the tokens, not the screens
Build one page that renders every semantic token on every surface: text on surface, text-muted on surface-raised, the accent on each. It takes an hour, and it finds the failures before a user does. Contrast in a dark UI has the numbers to check against.
If you want a dark system that already works this way, Glass Instrument UI is ours: the full token file of a live dark product, with the surfaces, the two-hue palette and the rule of one lime action per screen written down so Claude follows them on every screen it builds.
Questions
What is the difference between a raw token and a semantic token?
A raw token names a colour by its place on a ramp, such as violet-400. A semantic token names it by its job, such as surface-raised or text-muted. Components use semantic tokens only, so a theme changes by remapping them.
Should dark mode use pure black?
Usually not. A near-black with a little of the brand hue in it gives raised surfaces room to be lighter, and it avoids the harsh edge pure white text has on pure black.
How do I stop dark mode drifting from light mode?
Keep one list of semantic tokens that both themes must define, and forbid raw colour values in component code. A missing token then shows up as an error, not as a slightly wrong grey.