The three tiers, and what each one is actually for
The convention is close to universal, which makes it easy to adopt the shape without the reasoning. Each tier answers a different question, and knowing which question keeps you from putting things in the wrong place.
| Answers | Example | Used by | |
|---|---|---|---|
| Primitive | What values exist in this design? | blue-600, space-4, radius-md | The semantic tier only. Never product code |
| Semantic | What is this value for? | --color-danger, --text-muted, --surface-raised | Product code. This is the layer people write against |
| Component | Where does one component legitimately differ? | --button-primary-bg, --tooltip-surface | That component only, and rarely |
The rule that makes this work, and the one most often broken: nothing outside the semantic tier may reference a primitive. The moment a component uses blue-600 directly, the primitive tier has become a public API and you can no longer change a value without auditing the whole codebase — which was the entire reason for having tiers.
:root {
/* Tier 1: primitives. Values only. Nobody uses these directly. */
--blue-600: #2563eb;
--red-600: #dc2626;
--gray-500: #6b7280;
/* Tier 2: semantic. Roles. This is the public API. */
--color-primary: var(--blue-600);
--color-danger: var(--red-600);
--text-muted: var(--gray-500);
/* Tier 3: component. Only where a component truly deviates. */
--button-danger-bg: var(--color-danger);
}
/* ✅ product code */
.alert { color: var(--color-danger); }
/* ❌ reaches past the semantic layer */
.alert { color: var(--red-600); }The naming pattern
Read left to right, general to specific: category, role, variant, state. Not every token needs all four, and the ones that do are usually interactive.
| Category | Role | Variant | State | |
|---|---|---|---|---|
color-text-primary | color | text | primary | — |
color-surface-raised | color | surface | raised | — |
color-action-primary-hover | color | action | primary | hover |
space-inset-lg | space | inset | lg | — |
border-subtle | border | — | subtle | — |
Consistency matters far more than which pattern you pick. A codebase where half the tokens read text-color-muted and half read color-text-muted costs everyone a lookup every time, forever. Pick one order, write it down, and enforce it in review.
One practical advantage of general-to-specific ordering: your tokens sort alphabetically into meaningful groups. All the color-surface-* tokens sit together in an editor's autocomplete, which turns the naming convention into a discovery mechanism.
Here is the pattern applied, rather than described. Read the role names, not the colours — the question each one answers is "what is this for", and a name that only answers "what colour is it" has failed the tier it sits in.
The test that catches a bad name
One question resolves most naming arguments in seconds:
If the design changed, would this name still be true?
Run it against real cases and the answers are unambiguous:
| Survives? | Why | |
|---|---|---|
--color-brand-blue | No | Rebrand to green and the name is a lie that nobody dares fix |
--color-primary | Yes | The role does not change when the hue does |
--text-small-gray | No | Two appearance facts, both of which will move |
--text-muted | Yes | Names the intent — de-emphasised text |
--shadow-card | No | Encodes the mechanism. Switch to hairline elevation and it is wrong |
--elevation-raised | Yes | Names the effect. It can be a shadow, a border, or a surface step |
The --shadow-card case is the subtle one, and worth dwelling on. Naming a token after its implementation locks the implementation in. Once every card in the codebase says box-shadow: var(--shadow-card), moving to border-based elevation is a refactor rather than a token change — which is exactly the coupling tokens were supposed to remove.
Dark mode is where appearance-based names die publicly. --gray-100 as "the light background" is correct in one theme and inverted in the other, so you end up with --gray-100 holding a near-black value and every reader confused. --surface-base is true in both.
Why the component tier keeps growing
Most token systems that fail, fail here. The component tier starts small and legitimate, then absorbs everything, and eventually you have four hundred tokens, half of which have one consumer.
The mechanism is always the same. Someone needs a button background that is not quite --color-primary. Adding --button-primary-bg takes thirty seconds and adding it to the semantic tier requires a conversation. So the component token goes in, and the next person does the same, and the semantic tier stops being the source of truth without anyone deciding that.
| The real problem | |
|---|---|
| Several components need the same off-semantic value | A missing semantic token. Name the role and promote it |
| One component needs a genuinely unique value | Legitimate. This is what the tier is for. It should be rare |
| A whole surface needs different values | A missing theme or scope, not component tokens. Look at what Encore did with layers |
| Nobody can tell which semantic token to use | The semantic tier is under-specified or badly named |
A useful audit: count component-tier tokens with exactly one consumer. If that number is large, the tier is being used as an escape hatch, and the fix is upstream.
Five mistakes, and what each one costs
These recur across almost every token system that stops being used. Each has a cheap fix if caught early and an expensive one if not.
- 1
Numbered scales with no anchor
color-1throughcolor-12tells nobody anything, and the numbers acquire meaning only through folklore. Numbered scales are fine in the primitive tier, where the number tracks a real dimension like lightness. They are never acceptable in the semantic tier, because the whole point of that tier is to say what something is for. - 2
Encoding the theme in the name
--light-bgand--dark-bgas separate tokens means every component references both and branches. One name, two values, switched by theme — that is what the token layer is for. If you findif (theme === 'dark')in component code, the tokens are named wrong./* ❌ */ --light-bg: #fff; --dark-bg: #0a0a0a; /* ✅ */ --surface-base: #fff; [data-theme="dark"] { --surface-base: #0a0a0a; } - 3
Sizes named after where they are used today
--text-herois fine until the hero size shows up in a pricing table.--text-4xlor--font-scale-7names the step, not the site, and survives being reused. Name by position in a scale, not by first customer. - 4
Status colours doubling as brand colours
The most common way a system becomes unable to show state. If the brand green and the success green are the same token, you cannot restyle the brand without changing what success looks like, and you cannot tell a healthy row from a branded one. Keep the status set reserved and say so in a comment.
- 5
Abbreviations that only one person expands
--clr-bg-scndrysaves eleven characters and costs every reader a decoding step forever, including a model that has to guess whetherscndryis secondary or a typo. Autocomplete makes length nearly free. Write the words.
The second one is worth auditing today. Grep your components for theme conditionals — every one you find is a token that should have been one name with two values, and each is a place where dark mode will silently diverge from light.
Names that survive leaving your codebase
Tokens increasingly have to travel: from a design tool to CSS, into a Tailwind theme, into a native platform, into a shadcn registry item, into a DESIGN.md an agent reads. That puts a constraint on names most teams do not think about until the first export breaks.
| Portable? | Why | |
|---|---|---|
| Dots or slashes as separators | Risky | color.text.muted is natural in JSON and illegal in a CSS custom property. Hyphens survive everywhere |
| Uppercase or mixed case | Risky | Some targets normalise case, some do not. Lowercase throughout removes the question |
| Deep nesting | Risky | color.semantic.text.emphasis.high flattens into something unreadable and nobody types it twice |
| Flat lowercase hyphenated | Yes | --color-text-muted works as a CSS variable, a JSON key, a Tailwind key and a plain word in prose |
The practical rule: pick names that are simultaneously a valid CSS custom property, a valid JSON key and a readable English phrase. Flat, lowercase, hyphenated satisfies all three, and the DTCG/W3C token format's nested groups can be generated from a flat set far more easily than the reverse.
One more portability test worth applying: can you say the token name out loud in a meeting without spelling it? If two people cannot agree on how to pronounce a token, they will not use it consistently in writing either.
Making design and code use the same names
The highest-leverage naming decision is not the pattern. It is whether the names in your design tool are identical to the names in code.
Salesforce's SLDS 2 is the clearest published example: its Figma library uses the same semantic hook names as the CSS — their own examples are radius-border-4 and font-scale-4 — so designs map one-to-one to live code. Their stated goal is a shared vocabulary bridging design and development.
The practical effect is that a whole class of handoff bug stops existing. When a designer says font-scale-4 and a developer types font-scale-4, there is no translation step and therefore nowhere for meaning to leak. Compare that with a Figma style called "Heading / Large" mapping to a CSS variable called --text-2xl, where every handoff is a lookup and every lookup is a chance to guess wrong.
If you can only make one change to your token system, make the names match across tools. It costs a rename and removes a permanent tax.
Naming when an agent is the consumer
Token naming used to be a human ergonomics question — autocomplete, readability, onboarding. Increasingly the heaviest reader of your token file is a coding agent, and that changes which failures matter.
A human who cannot tell which of two greys to use will ask, or pick one and be told in review. A model will pick one, silently, in every file it touches, and the inconsistency arrives faster than review can catch it. Ambiguity in the semantic tier is a much more expensive defect than it used to be.
We sampled 299 DESIGN.md files published for agents to read and found that 86% specify colours as raw hex values with no semantic role attached at all. Not badly-named tokens — no tokens. A model given #6b7280 and told it is part of the palette will use it wherever a mid-grey is plausible: body text, borders, icons, placeholders, disabled states. Five different jobs, one value, no way to change any of them independently later.
| What the agent does | |
|---|---|
Gray: #6b7280 | Uses it for body text, borders, icons, placeholders and disabled states alike |
--text-muted: #6b7280 — secondary and supporting text only. Borders use --border-subtle. Disabled uses --text-disabled. | Uses it for supporting text. The other jobs have their own names, so they can diverge later |
The second version costs three extra lines and buys the ability to darken your borders without darkening your captions — which is a change you will want, and which the first version makes impossible without a codebase-wide audit.
Identity Forge design kits ship 28 semantic colour roles across light and dark, plus type, spacing and elevation, serialised into a DESIGN.md an agent reads before writing anything. Browse the kits, or read semantic colour tokens explained.
A starting set
If you are naming a system from scratch, this is a defensible minimum. It is small on purpose — a system nobody can hold in their head gets ignored.
/* Surfaces — what things sit on */
--surface-base /* the page */
--surface-raised /* cards, panels */
--surface-overlay /* modals, popovers */
--surface-sunken /* wells, inset areas */
/* Text — by emphasis, never by colour */
--text-primary
--text-secondary
--text-muted
--text-disabled
--text-on-accent /* text sitting on the accent colour */
/* Borders — by weight of presence */
--border-subtle
--border-strong
--border-focus
/* Action — the interactive colour and its states */
--action-primary
--action-primary-hover
--action-primary-active
/* Status — reserved. Nothing decorative uses these. */
--status-success
--status-warning
--status-danger
--status-infoTwo notes on that set. Text tokens are named by emphasis rather than colour, so they stay true in dark mode. Status tokens carry a comment reserving them, because the most common way a status system breaks is a designer using the success green as a decorative accent and then nobody being able to tell a healthy row from a branded one.
What is the best design token naming convention?
Three tiers — primitive, semantic, component — with names read general to specific: category, role, variant, state. The specific ordering matters far less than applying one consistently, because the real cost of inconsistency is a lookup on every single use.
What is the difference between primitive and semantic tokens?
A primitive names a value (blue-600) and says nothing about where it belongs. A semantic token names a job (--color-primary) and points at a primitive. Product code should only ever use semantic tokens, so that changing a value is a one-line edit rather than a codebase audit.
Should I name tokens after colours?
Only in the primitive tier, where naming the value is the point. In the semantic tier, never: --color-brand-blue becomes a lie the moment you rebrand, and nobody renames it because too much depends on it. Name the role and let the value move underneath.
How many design tokens should a system have?
Fewer than you think. A semantic layer of roughly 25 to 40 colour roles plus type, spacing and elevation scales covers most products. If the count is climbing past a hundred, check how many have exactly one consumer — that number tells you whether you have a system or a list.
Do token names matter more now that AI writes the code?
Yes, because the failure mode changed. A human unsure which grey to use asks or gets corrected in review. A model picks one silently in every file it touches, so ambiguity spreads faster than review catches it. Unambiguous role names with stated purposes are the fix.