One --token to rule them all
Every design system I have inherited had the same original sin: components that knew what colour they were. A button holding #2563eb somewhere in its styles is a button that has to be found, edited and re-tested the day the brand shifts, the day dark mode ships, the day a second product wants the library. Multiply by every component and theming becomes an archaeology project.
The fix is old and boring and still under-used: stop letting components author colour at all. Split your tokens into tiers and enforce one rule — components may only read the middle tier.
The three tiers
Tier one is primitives: raw scales with no opinion about usage. gray-0 through gray-900, an accent ramp, a spacing scale. A primitive never appears in component code. Its only job is to exist and be pointed at.
Tier two is semantic: each token answers a question rather than naming a colour. What is the page background? --bg. What is muted text? --text-2. What is a hairline border? --line. Each answer is a pointer at a primitive.
:root {
--bg: var(--gray-0);
--text: var(--gray-900);
--line: var(--gray-200);
}
[data-theme="dark"] {
--bg: var(--navy-950);
--text: var(--mist-100);
--line: var(--navy-700);
}Tier three is components, and they are consumers, never authors. A card reads var(--surface) and var(--line) and knows nothing else. That ignorance is the entire feature: dark mode is now one attribute flip on the html element, because the semantic layer re-points and every consumer follows.
Derived tokens are the sleeper hit
There is a third kind of token that is neither primitive nor a plain pointer: the computed one. This site derives a high-contrast accent and a soft halo from whatever the current accent happens to be:
--accent-strong: color-mix(in oklab, var(--accent) 64%, var(--text));
--halo: color-mix(in oklab, var(--accent) 16%, transparent);Because --text itself flips with the theme, --accent-strong re-derives for every theme-times-accent combination for free. Sixteen visual permutations, zero extra declarations. One caveat that cost me an evening: custom properties resolve where they are defined, so derived tokens must live on the same selector that defines the accent, or they capture stale values.
What it costs
One CSS file and a naming argument. That is genuinely the whole bill. No runtime, no build step, no JavaScript theming library holding your colours hostage in a JS object. The browser has been shipping this capability since 2016; color-mix() closed the last gap. If your components still know their own hex codes, they know too much.