Design Tokens for Developers: Stop Writing Hex Codes
A dark mode that took an afternoon instead of a sprint, and the naming scheme that made it possible.
I've added dark mode to two apps. The first took most of a sprint and shipped with a dozen unreadable corners we kept finding for weeks. The second took an afternoon. The difference wasn't skill — it was that the second one had tokens and the first had 200 hardcoded hex values.
The problem with a hex code in a component
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
color: #111827;
}Three decisions frozen in a place where they can't be revisited. Dark mode means finding every one of them. And you will miss some, because #fff, #ffffff, white, and rgb(255,255,255) are the same colour to a browser and four different strings to a grep.
Name by role, never by appearance
This is the whole idea, and it's where most token systems go wrong on the first day.
/* Wrong — the name describes what it looks like */
--color-white: #ffffff;
--color-light-gray: #f3f4f6;
--color-blue-600: #2563eb;
/* Right — the name describes what it's for */
--surface: 0 0% 100%;
--surface-muted: 220 14% 96%;
--primary: 221 83% 53%;--color-white: #1a1a1a in dark mode is an absurd line of code. --surface: 220 13% 9% is a sensible one. The role-based name stays true when the value flips; the appearance-based name becomes a lie.
Two layers, not one
A primitive layer that holds raw values, and a semantic layer that assigns them meaning:
:root {
/* primitives — the palette, no meaning attached */
--blue-600: 221 83% 53%;
--gray-50: 220 14% 98%;
--gray-900: 220 13% 9%;
/* semantics — what the UI actually references */
--background: var(--gray-50);
--foreground: var(--gray-900);
--primary: var(--blue-600);
}
.dark {
--background: var(--gray-900);
--foreground: var(--gray-50);
}Components only ever touch the semantic layer. That means a rebrand is an edit to the primitives, and a theme is an edit to the semantics — and neither requires opening a single component.
Store HSL channels, not colours
Note the values are 221 83% 53%, not hsl(221 83% 53%). Storing bare channels lets you compose alpha at the point of use:
.button-ghost:hover {
background: hsl(var(--primary) / 0.08);
border-color: hsl(var(--primary) / 0.4);
}Without it you need a separate token for every opacity — --primary-10, --primary-20 — and the set is never quite the one you need. This one detail eliminates a whole category of token sprawl. (Tailwind's hsl(var(--x)) convention exists for exactly this reason.)
Tokens beyond colour
Colour is the obvious one, and the others matter nearly as much for consistency:
- Spacing — a 4px scale. Nothing is ever 13px. Arbitrary spacing is why layouts feel subtly off without anyone being able to say why.
- Radius — one
--radiuswithcalc()derivatives. Three radii on one card is the fastest way to look unfinished. - Shadow — two or three, named by elevation.
--shadow-smfor resting cards,--shadow-lgfor anything floating. - Type scale — a fixed ramp. The moment someone needs 17px, the scale has lost.
- Motion — one duration and one easing curve for ordinary transitions. Inconsistent timing is felt even when it isn't noticed.
Contrast is a constraint, not a preference
Check every foreground/background pair against WCAG AA — 4.5:1 for body text, 3:1 for large text and meaningful UI boundaries — in both themes. The pair that always fails is muted text on a muted surface, because it looks fine on your calibrated monitor at full brightness in a dark room.
Do this once when you define the tokens and it's permanently handled. Do it per component and you'll do it never.
One rule to enforce
No raw colour values in components. Ever. Lint it if you can — a rule banning hex literals outside your token file takes ten minutes to add and holds the entire system together.
Without enforcement, someone in a hurry hardcodes #fafafa because it's 'basically the same', and six months later you're back to grepping for four spellings of white.