Semantic tokens and the rebrand that broke everything
A story about --blue-500, told as a cautionary tale, with the two-layer token structure that prevents it.
↑ Click any of these to repaint the entire site with it.
A team ships a design system. The palette is generated properly — an eleven-step ramp per hue, perceptual lightness curve, contrast-checked. The tokens are named after what they are: --blue-500, --blue-600, --gray-200. Clear, predictable, easy to document.
Eighteen months later the company rebrands from blue to green.
The find-and-replace touches four hundred files. Some of the --blue-600 references were primary buttons, which should become green. Some were informational callouts, which should stay blue because they were never brand — they were semantic. Some were in a chart palette where blue was one of six categorical colors and changing it breaks the series ordering. Nobody remembers which is which, because the token name records the color and not the reason.
Six weeks. Two regressions in production. One of them shipped a green "information" callout that users read as a success message.
The fix is one extra layer#
/* Layer one: primitives. Generated. Named for what they are. */
:root {
--blue-500: oklch(62% 0.19 258);
--blue-600: oklch(55% 0.19 258);
--green-600: oklch(55% 0.17 152);
--gray-200: oklch(92% 0.008 258);
}
/* Layer two: semantics. Hand-authored. Named for what they do. */
:root {
--color-action: var(--blue-600);
--color-action-hover: var(--blue-700);
--color-info: var(--blue-500);
--color-border: var(--gray-200);
}Components only ever reference layer two. The rebrand becomes one line:
--color-action: var(--green-600);--color-info stays blue, because it was never the brand color — it just happened to be the same hue.
Naming layer two#
Name for role, not appearance. The test is whether the name would still be true if the color changed.
| Bad | Good | Why |
|---|---|---|
--blue-button | --color-action | The button might not stay blue |
--light-gray-bg | --color-surface | It is dark gray in dark mode |
--red-text | --color-danger | Red is the current implementation |
--border-1px | --color-border-subtle | Encodes a size in a color token |
--brand | --color-action or --color-brand | "Brand" is ambiguous — is it the fill or the accent? |
The trap in that last row is worth dwelling on. --brand gets used for the logo color, the primary button, the link color and the focus ring, and then somebody needs the button to be darker than the logo and there is nowhere to put that.
How many semantic tokens#
Fewer than you think. A product UI needs roughly:
/* surfaces */
--color-bg
--color-surface
--color-surface-raised
/* lines */
--color-border
--color-border-strong
/* text */
--color-text
--color-text-muted
--color-text-subtle
/* interactive */
--color-action
--color-action-hover
--color-on-action
/* semantic states */
--color-success
--color-warning
--color-danger
--color-infoFifteen. If you are at eighty, you have primitives leaking into layer two, or you have a token per component, which is the same problem wearing a hat.
The dark mode payoff#
The two-layer structure is what makes theming tractable. Layer one does not change between themes — --blue-600 is --blue-600 in both. Layer two remaps:
:root {
--color-bg: var(--gray-50);
--color-action: var(--blue-600);
--color-on-action: white;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--gray-950);
--color-action: var(--blue-400);
--color-on-action: var(--blue-950);
}
}Note that --color-action moves from 600 to 400. That is the chroma-and-lightness adjustment dark themes need, expressed as a token remap rather than a new color. And note --color-on-action flipping from white to a dark blue — the text on a light-blue button has to be dark, and if you had hardcoded white in the button component you would not have anywhere to express that.
One rule to enforce in review#
If a component file contains a hex code or a primitive token name, it is a bug. Not a style preference — a bug, in the same category as a hardcoded string that should have been in the localisation file.
It is easy to lint for and it is the only thing standing between you and the six-week rebrand.