Design Tokens in React and Angular: A Practical Guide
I've shipped design tokens as SCSS variables at Base Superadmin, as a shared Angular and React token set at QKLY, and as CSS custom properties with Tailwind on D'ARK. The mechanism changes every time; the underlying discipline doesn't.
What is a design token?
A design token is a single named value — a colour, a spacing unit, a font size, a corner radius — stored once and referenced everywhere, instead of that value being retyped as a raw number or hex code in every component that needs it.
`#C1420F` hard-coded in six components is six places to update when the brand colour changes. `--color-warm` referenced in six components is one. That is the whole idea, and everything else in this article is about making that idea survive contact with a real codebase.
Why do tokens matter more once there's more than one codebase?
The moment a product has both an Angular admin panel and a React customer app — which is exactly the situation I worked in at QKLY — a colour or spacing value that exists only as a number in each framework's stylesheet will drift the first time either one is updated in isolation.
It never drifts dramatically. It drifts by one shade, or two pixels, on one screen, and it stays wrong for months because nobody has both apps open side by side. Then a client notices, and what should have been a one-line change becomes an audit of every stylesheet in two repositories.
Tokens solve this by moving the value out of any single framework's syntax and into a neutral format both can consume: typically JSON, sometimes CSS custom properties directly. Each framework then has a thin adapter layer that turns the neutral token into whatever that framework's styling approach expects.
A structure that survives contact with two frameworks
The structure that has held up across every platform I've built on is a two-tier system: primitive tokens, which are raw values with no meaning attached, and semantic tokens, which name a purpose and point at a primitive.
- Primitive: `--blue-600: #2B3A8F` — a colour, nothing more. Primitives are a palette, not a set of decisions.
- Semantic: `--color-action: var(--blue-600)` — this is the token components actually use, and its name describes a job rather than an appearance.
- Components reference semantic tokens only. A component that reaches past the semantic layer to a primitive has quietly opted out of theming.
- Rebranding, dark mode or a white-label client theme then means repointing the semantic layer, never touching a component.
How to name tokens so the names survive a rebrand
Naming is where token systems are usually won or lost, and the rule is simple: semantic tokens describe purpose, never appearance. `--color-danger` survives a rebrand. `--color-red` does not, because the day the danger state becomes orange you are left with a token called red that is orange, and every engineer who reads it will assume it is a mistake.
The same applies to spacing and type. `--space-card-gutter` communicates intent; `--space-16` communicates a number that will be wrong the moment the scale changes. In practice most systems need both — a numeric primitive scale and a semantic layer above it — which is precisely why the two tiers exist.
One more naming discipline worth enforcing: keep the token name identical across every platform. If React reads `color-action` and Angular reads `actionColour`, you have introduced a translation step, and translation steps are where drift lives.
Which values should not be tokens
Tokenising everything is its own failure mode. A token exists to make a value changeable in one place, so a value that will never change in concert with anything else gains nothing from being named — it just adds a layer of indirection between the developer and the number.
The test I use: if changing this value alone, without changing anything else, would be a bug, it should be a token. If changing it alone is a legitimate, isolated tweak to one component, leave it as a literal in that component and move on.
In practice this means colour, the spacing scale, the type scale, radii, border widths, shadow and motion durations are tokens. A one-off `margin-top: 3px` optical adjustment on a single icon is not, and naming it `--space-icon-nudge` makes the system harder to read rather than easier.
Type and spacing scales deserve as much care as colour
Colour tokens get all the attention because rebrands are visible. In day-to-day work, the spacing and type scales are what actually determine whether an interface looks considered, and an undisciplined scale is the most common reason a design system fails to make anything look better.
Keep the spacing scale short. Four to seven steps, related by a consistent ratio, is enough for almost any interface, and a short scale forces the useful conversation — is this gap a medium or a large? — instead of allowing a bespoke value per screen. The moment the scale has fourteen steps, it has stopped constraining anything.
The same holds for type. A scale of five or six sizes with defined line heights and weights covers a full product. Line height is the part most often left out of the token layer and then set ad hoc in components, which is precisely how vertical rhythm falls apart across a codebase.
Consuming tokens in React
With Tailwind v4, as used on this site, tokens are declared once in an `@theme` block in CSS and become both CSS custom properties and Tailwind utility classes automatically. `--color-warm` is usable as `var(--color-warm)` and as the `text-warm` and `bg-warm` utilities, with no separate config file to keep in sync. That single-source behaviour is the main reason I moved to v4 for D'ARK.
Without Tailwind, the same primitive and semantic JSON can be consumed as CSS custom properties on `:root`, or piped through a tool like Style Dictionary to generate a typed constants file for styled-components or CSS-in-JS. The typed variant has a real advantage on larger teams: a mistyped token name becomes a compile error rather than a silently missing style.
Consuming tokens in Angular
Angular projects most commonly consume the same token JSON via SCSS maps, generated at build time so the SCSS variables and the React CSS custom properties are built from an identical source file rather than maintained as two hand-written lists that can silently diverge.
The component layer in Angular then references the SCSS variables exactly the way a React component references a Tailwind class. The framework differs, the source of truth does not.
One Angular-specific caution: view encapsulation means CSS custom properties set on a component host do not always cascade the way people expect when mixed with `::ng-deep`. Declaring the semantic layer at `:root` and treating component-level overrides as an exception avoids most of the surprises.
Theming, dark mode and white-label clients
Once the semantic layer exists, theming is almost free, and this is the point at which the two-tier structure pays for itself. A theme is a second set of semantic assignments — the same token names pointing at different primitives — scoped to a selector or a data attribute on the root element.
Dark mode is the simplest case: `[data-theme="dark"]` reassigns `--color-surface`, `--color-text` and the rest, and every component inherits the change without a single component-level condition. White-label works identically, with one assignment block per client brand.
The failure mode to watch for is a component that hard-codes a colour “just for dark mode”. The moment that appears, the theme is no longer expressible in tokens, and the third theme will be twice as expensive as the second.
Migrating a codebase that's already full of hex codes
Very few of us get to start clean. The migration that works is incremental and starts with measurement rather than refactoring.
- Grep the codebase for hex codes and hard-coded pixel values. Sort by frequency. The top twenty values are your primitive palette, whether you intended them to be or not.
- Cluster the near-duplicates — the four blues that differ by two hex digits — and pick one. This is a design decision, not an engineering one; get it made and recorded.
- Introduce the token layer alongside the existing values rather than replacing them. Nothing breaks on day one.
- Migrate per component, as part of work you are already doing in that file. Avoid the big-bang refactor pull request; it will be too large to review and too risky to merge.
- Add a lint rule that fails on new hard-coded colours once the top-traffic components are converted. Stopping the bleeding matters more than finishing the migration.
Stopping drift with a build step and a CI check
Generation is what separates a token system that holds from one that erodes. A single JSON or YAML source, built into each framework's format as a build step, means the two outputs cannot disagree — they are produced by the same command from the same input.
Add one CI check on top of that and the system becomes genuinely self-enforcing: fail the build if the generated files in the repository differ from what the generator produces. Someone who hand-edits the generated SCSS will find out in the pull request rather than in a client's browser six weeks later.
A visual regression snapshot on a handful of representative components catches the remaining category of error — the token that changed correctly but broke a layout that depended on its old value.
The one rule that keeps a token system honest
Generate, don't duplicate. The instant a token value is typed by hand into a second file, in a second syntax, the two files will eventually disagree, and nobody will notice until a client's brand colour is subtly wrong on one platform but not the other.
Everything else in this article — the naming discipline, the two tiers, the theming model, the CI check — exists to protect that one rule. A single source, built into each framework's format automatically, is what makes a multi-framework design system trustworthy rather than aspirational.