Every design system survives its first re-brand or reveals that it was never a system at all. The moment the brand colour changes from blue to green, you find out whether you built a set of named decisions or just a spreadsheet of hex codes with friendlier labels. Tokens that survive that day share a particular structure, and it is worth understanding before you need it.
The failure mode is easy to describe. A team defines a token called --blue-500, uses it in three hundred places, and feels organised. Then the brand goes green. Now --blue-500 holds a green value, which is absurd, or the team runs a find-and-replace across three hundred usages, which is exactly the manual churn tokens were supposed to prevent. The name encoded the wrong thing. It described the value, not the value's job.
The three-tier structure
The durable approach separates tokens into layers, each referring to the one below it. The names differ between teams but the roles are consistent.
The bottom layer is the primitive — the raw palette. These names do describe values, and that's fine, because that is all they are: a catalogue of the colours that exist.
--color-blue-500: #2f6fed;
--color-green-500: #1f9d57;
--color-slate-900: #14161a;
The middle layer is the semantic token. This is the one that matters. Its name describes a role, and its value points at a primitive. Nothing in your components ever references a primitive directly; they reference these.
--color-brand: var(--color-blue-500);
--color-text: var(--color-slate-900);
--color-danger: var(--color-red-500);
The top layer, when you need it, is the component token — a role scoped to one context, pointing at a semantic token.
--button-primary-bg: var(--color-brand);
Why this makes the re-brand a one-line change
Now replay the blue-to-green switch. Your components all reference --color-brand. That token points at a primitive. To re-brand, you change one line:
--color-brand: var(--color-green-500);
Three hundred usages update because none of them named a colour — they named a role, and the role's value moved. The primitive palette didn't change; blue still exists in the catalogue. What changed is which primitive the brand role points to. That indirection, one extra hop, is the entire trick.
Naming is the hard part, not tooling
Teams tend to believe the challenge is the build pipeline — the tool that transforms tokens into CSS, iOS, and Android outputs. That part is largely solved. The genuinely hard part is deciding what your semantic layer should contain, because that is a design act, not an engineering one. Get it too granular and you have a semantic token per component that provides no consolidation. Get it too coarse and you cannot express real differences, so people reach past the system to primitives, and the whole structure quietly erodes.
A test that helps: a semantic token name should survive a re-brand unchanged. --color-brand passes — a green brand is still the brand. --color-primary-action passes. --color-blue fails on sight. If the name would be a lie after the values change, it belongs in the primitive layer, not the semantic one.
The same structure gives you theming for free
The reason this pattern is worth the discipline is that it pays off more than once. The indirection that makes re-branding cheap makes dark mode, high-contrast mode, and per-product theming cheap too — they are all the same operation. Each mode is just a different set of values for the semantic layer, with the component layer untouched.
:root {
--color-surface: var(--color-white);
--color-text: var(--color-slate-900);
}
[data-theme="dark"] {
--color-surface: var(--color-slate-900);
--color-text: var(--color-slate-100);
}
Nothing in any component changed. The surface and text roles took new values in a new context. A design system that can do this wasn't over-engineered — it was built with one honest assumption baked in: that the values will change, and the names must not. Build the layer of roles between your components and your palette, and the re-brand that breaks other systems becomes an afternoon.

