A design system survives its first rebrand or it does not. Most do not, and the reason is almost always the same: the system has two layers where it needs three.
The two-layer trap
Here is the shape nearly every token system starts with. A palette, and components that consume it.
:root {
--blue-500: #00d4ff;
--gray-900: #18181b;
}
.card {
background: var(--gray-900);
border-color: var(--blue-500);
}
This is already better than hardcoded hexes, and it feels like the job is done. It is not, and the failure mode is specific.
Six months in, someone asks for a light theme. --gray-900 is the card background in dark mode and needs to be near-white in light mode. So you either rename the variable to something that is now a lie, or you write --gray-900: #ffffff under [data-theme="light"], which is worse.
Then the rebrand lands, blue becomes violet, and you discover --blue-500 is referenced in 340 places — some of which meant "our brand colour" and some of which meant "the colour of a link" and some of which meant "this particular chart series." Those are three different concepts that happened to share a hex.
The layer you are missing
Primitives describe what a value is. Semantics describe what it is for. You need both, and components may only touch the second.
/* 1. Primitives — raw values, no product meaning */
:root {
--cyan-500: #00d4ff;
--neutral-900: #18181b;
--neutral-0: #ffffff;
}
/* 2. Semantics — roles, which flip per theme */
:root,
[data-theme='dark'] {
--surface: var(--neutral-900);
--primary: var(--cyan-500);
--border: rgb(255 255 255 / 0.08);
}
[data-theme='light'] {
--surface: var(--neutral-0);
--primary: #0093b8;
--border: rgb(9 9 11 / 0.09);
}
/* 3. Components — roles only */
.card {
background: var(--surface);
border-color: var(--border);
}
Now the light theme is a block of overrides in one place, and the rebrand is an edit to the semantic layer. The 340 call sites do not move, because they were never asking for blue. They were asking for "primary," and primary is now violet.
Note also that --primary is not the same hex in both themes. #00D4FF on white fails contrast badly; the light theme needs a darker cyan to clear WCAG AA. A two-layer system cannot express that without a conditional at every call site. A three-layer system expresses it once.
Naming the semantic layer
The semantic layer is only useful if the names describe roles rather than appearance. Some rules that hold up:
- Name the job, not the look.
--surface-raised, not--gray-800.--danger, not--red. - Pair foreground with background. Every surface token gets a matching foreground token, checked for contrast once, so no component has to think about it.
- Encode state in the name.
--surface-hover,--surface-active. If hover is derived at the call site withcolor-mixor an opacity modifier, you have leaked appearance logic back into components. - Resist the urge to add a third brand colour. Most systems that end up with
--tertiarydid so because someone needed a specific shade once. That is what a one-off class is for.
Wiring it to Tailwind v4
Tailwind v4's @theme generates utilities from CSS variables, and it has an inline variant that matters here.
@theme inline {
--color-surface: var(--surface);
--color-primary: var(--primary);
--color-border: var(--border);
}
@theme inline keeps the var() reference live in the generated utility, so bg-surface emits background-color: var(--surface) and re-resolves when the theme attribute changes. Without inline, Tailwind resolves the value at build time and your theme switch silently does nothing — a bug that is genuinely unpleasant to track down because the CSS looks correct.
Static scales that never flip — spacing, radius, duration, easing — go in a plain @theme block. Only the colour roles need inline.
The test
There is one question that tells you whether your token layer is real:
If the brand colour changed tomorrow, how many files would you edit?
If the answer is more than one, you have a naming convention, not an architecture. The gap between those two is roughly a day of work, and it is the difference between a rebrand being a variable change and a rebrand being a quarter.
Where this stops being worth it
For a marketing site with six components, three layers is overhead. The break-even is somewhere around the point where you have a second theme, a second product, or a second team — whichever arrives first. Below that, a flat palette is fine and you should not let anyone tell you otherwise.
Related reading
Designing for models that are wrong
Every AI feature has a failure rate. Most interfaces are designed as though it were zero, and users learn to distrust the whole product.
Your component API is a contract, so write it down
Variant tables, prop naming and the boolean that should have been an enum. Notes from maintaining component libraries other people have to use.
Dense tables that stay readable
Row height, alignment, tabular figures and the filter model. What actually makes a 40,000-row table usable rather than merely displayable.
One useful email a month
Design system patterns, front-end techniques and case study breakdowns. No promotions, no digest of other people's links.


