Structuring color tokens
Sep 18, 2026 · Color · 3 min read
Sooner or later it happens: the brand color changes. In a well-built product, that is a one-line change and a coffee. In most products, it is a week of searching for hex codes and guessing which blues are safe to touch.
The mess builds slowly. Colors start as a list of hex codes, then blue-500 ends up on links, buttons, focus rings and one info banner, and nobody remembers which use is which. Two layers of tokens prevent that.
Primitives are values, tokens are jobs
Primitives are the raw palette: green-500, gray-200. Semantic tokens describe a job: the color of secondary text, the background of a hovered row, the border of an input. Components should only ever use the second kind.
:root {
/* Primitives: the palette. Nothing outside this file uses them. */
--gray-100: #fcfcfc;
--gray-400: #e8e8e8;
--gray-1100: #646464;
--green-500: #00bc7d;
/* Semantic tokens: what components use. */
--color-bg: var(--gray-100);
--color-border: var(--gray-400);
--color-text-secondary: var(--gray-1100);
--color-accent-solid: var(--green-500);
}Now a rebrand is a one-line change, and dark mode is just a second set of assignments for the same semantic names.
Name tokens by role, not by color
--color-green-button is a trap: the day the button turns purple, the name becomes a lie. Name the role instead.
Still true after a rebrand, whatever the color becomes.
Says green, renders violet. The name lied the day the brand changed.
Retint one panel and every surface that borrowed the token moves with it.
The same logic says to call the brand color accent, not primary. In many systems primary also means the darkest text color, and then nobody can tell which one a component meant.
- heading
- --color-primary
- body
- --color-secondary
- button
- --color-primary
- heading
- --color-text-primary
- body
- --color-text-secondary
- button
- --color-accent-solid
Every gray should earn its place
Palettes generated by a tool often come with twelve grays. Most products use five or six. Map each step to a real job (page background, subtle fill, hover, border, muted text, body text) and delete the rest. Fewer steps mean fewer near-duplicate choices, and a more consistent product.
Check contrast where the text actually sits
Contrast is a ratio between two colors, so the second color matters. Gray text that passes on the white page can fail on a gray card or a tinted banner.
This label sits on a card, not on the page.
- Body text needs at least 4.5:1 against its actual background.
- Large text (roughly 24px, or 19px bold) needs 3:1.
- Icons and the edges of controls need 3:1 to be seen.
Dark mode is its own palette
Inverting a light theme gives you pure black backgrounds and pure white text, which is harsh and makes bright elements glow. Build the dark palette separately: backgrounds in the near-black range, text slightly off-white, accents a little less saturated, and elevation shown by making surfaces lighter rather than adding shadows nobody can see.
Then pick one mechanism for switching, either the prefers-color-scheme media query or a .dark class (usually both, with the class winning), and apply it to every token. Mixing mechanisms is how you end up with a dark page containing a light dropdown.
Checklist
- Components use semantic tokens only; primitives stay in the theme file.
- Tokens are named by role, and the brand color is
accent. - Every gray step has a documented job.
- Contrast is checked on the real surface, 4.5:1 for body text.
- Dark mode is designed, not inverted, and switched by one mechanism.
This piece expands on the Color rules in my cheat sheet, where every rule has a live example.
Keep readingAccessibility basics for interfacesFocus rings, native elements, hit areas, forms and motion preferences: the fixes with the best return for the least work.