Skip to main content

Design Tokens in CSS: The 20% That Carries the Whole Design System

· 20 min read
Pere Pages
Software Engineer
A tall shelving unit seen head-on. The bottom shelf holds a small number of large, labelled jars of pigment, each with one clear tag. The shelves above hold many smaller unlabelled bottles, and thin threads run from each small bottle down to one of the big labelled jars it was poured from.

I am building a design system, and the part I keep coming back to is not the components. It is the hundred or so named decisions underneath them. Design tokens are the twenty percent of a design system that decides whether the other eighty holds together, and the part of them you actually have to understand fits in one careful read.

The picture to hold in your head

A design token is a named design decision, owned in one place and referenced everywhere else, and a design system is the set of those decisions plus the components that consume them. The Cascading Style Sheets (CSS) custom properties you write are one output of the tokens, not the tokens themselves. That distinction is the whole foundation, and most token trouble comes from collapsing it.

The twenty percent worth understanding deeply is five things:

  1. A token is a decision, not a variable.
  2. Tokens live in three tiers, and references flow one way between them.
  3. Names follow a grammar, not a list.
  4. Custom properties have a handful of mechanics that make theming work, and one that quietly breaks it.
  5. There is one source of truth, and the CSS is generated from it.

Everything else, the tooling, the design-tool sync, the multi-brand machinery, is the eighty percent you can add later without redoing anything, provided the five are in place.

A token is a decision, not a variable

The term comes from Salesforce, where Jina Anne, founding designer on the Lightning Design System, paired with engineering to architect and implement design tokens and to build Theo, the first tool that generated them. What that work established is that tokens are a way of working, not a feature of a stylesheet: the same decision, authored once, delivered to every platform that needs it.

The difference is not the storage mechanism. A variable is where a value lives. A token is a decision that has a name saying what it is for, a value, a type, and a life that outlasts any one stylesheet. A --gap: 12px written inside a card's rule is a variable. --space-3 in a spacing scale is a primitive token. --color-text-muted is a semantic token. The test is simple: would a designer recognise the name, and would changing the value be a design decision rather than a code change? If both are true, it is a token.

What earns a token is narrower than people expect. The categories below are what a system genuinely needs, and the third column is the honest answer to whether you need each one before shipping anything.

CategoryExamplesNeeded on day one?
Colourtext, surface, border, action, statusYes
Spacethe spacing scale, inset and stack sizesYes
Typographyfamily, size scale, weight, line heightYes
Radius and border widthcorner sizes, hairlineAlmost
Shadowtwo or three elevation levelsAlmost
Motionone duration pair, one easingLater
Layeringa short z-index ladderLater
Breakpointscontainer and viewport stepsRarely as CSS tokens

Breakpoints get the weak mark for a mechanical reason: media queries cannot read custom properties, so a breakpoint token only works in the source of truth and in generated code, never as a var() in CSS. What does not earn a token is a one-off measurement that exists in a single component, or the arithmetic of a layout. Tokens are decisions, and a decision made once, for one place, is just a value.

The three tiers, and the one rule between them

Tokens live in three tiers, and references only ever flow from the specific tier to the general one. This is the structural idea everything else depends on, so it is worth being precise about what each tier is.

The primitive tier is the palette and the scales: every colour step, every spacing step, every font size, named by what they are and carrying no meaning. --blue-600, --space-4, --font-size-300. Primitives are exhaustive and boring, and components never touch them directly.

The semantic tier is where the decisions live. Each semantic token names a role in the interface and points at a primitive: --color-text-default, --color-surface-raised, --color-action-primary, --color-border-focus, --space-inset-md. This is the only tier components are allowed to consume. It is smaller than the primitive tier, and it is the tier a theme redefines.

The component tier is optional. --button-background, --card-padding. A component token exists only when a component needs a documented slot that someone will override from outside, a button inside a dark banner, say. By default a component reads semantic tokens directly and has no tokens of its own.

The rule that keeps this honest: a primitive references nothing, a semantic token references a primitive (or, sparingly, another semantic token when that relationship is the decision, as in a focus ring that should always match the action colour), and a component token references a semantic one. Never sideways within the primitive tier, never upwards.

TierRoughly how manyChanges whenConsumed by components
PrimitiveHundredsThe palette or scale changesNo
SemanticDozensA design decision or a theme changesYes
ComponentA handfulA component grows an override needYes, by that component

Two failures show up in nearly every system that skips this. The first is the primitive in disguise: --color-primary-500 used in forty components is a primitive with a hopeful name, and the day the brand colour changes you discover that "primary" meant six different things. The second is theming the primitive tier: making dark mode by flipping --gray-100 to a dark value means --gray-100 now lies, and every place that used it for its lightness rather than its role breaks. Themes redefine semantic tokens, and semantic tokens are the reason the tier exists.

Naming is a grammar, not a list

Once there are tiers, the names have to say which tier a token is in and what it does, and the only way that scales past a few dozen tokens is a grammar. Nathan Curtis's Naming Tokens in Design Systems is the reference: a name is a path of levels, read from broad to specific, with a fixed order.

The levels worth adopting are these, in this order, each one optional but never reordered:

  1. Namespace: a short prefix that keeps your tokens from colliding with a framework's. This site prefixes its own with --pp- and lives alongside Infima's --ifm- tokens without confusion.
  2. Category: color, space, font, radius, shadow, duration.
  3. Concept or property: text, surface, border, action, inset, stack.
  4. Variant: primary, muted, raised, danger.
  5. State: hover, active, disabled, focus.
  6. Scale: 100900, sm/md/lg, or a plain number.

Two rules do most of the work. Semantic tokens are named by role, never by value, and primitive scales are numeric with room between the steps. Role naming is what lets a theme change a value without making the name a lie. Numeric scales with gaps (100 to 900, or a spacing ladder of 1, 2, 3, 4, 6, 8, 12, 16) let you add a step without renaming its neighbours, which t-shirt sizes never allow: once you have sm, md and lg, the next size is xs or xl, and after that the scheme collapses into 2xl, 3xl and arguments.

NameVerdictWhy
--color-text-mutedGoodCategory, concept, variant; says the role
--pp-space-inset-mdGoodNamespaced, category, concept, scale
--blue-600Fine as a primitiveValue-named, which is exactly right for that tier
--color-primary-500 in a componentBadA primitive pretending to be semantic
--gray-100 redefined by the dark themeBadThe name no longer describes the value
--btn-bg-hover-darkBadTheme baked into the name; the theme is a scope, not a suffix
:root {
/* primitive: what it is */
--pp-gray-100: oklch(97% 0.005 270);
--pp-gray-900: oklch(22% 0.02 270);
--pp-indigo-600: oklch(50% 0.2 275);
--pp-space-2: 0.5rem;
--pp-space-4: 1rem;

/* semantic: what it is for */
--pp-color-text-default: var(--pp-gray-900);
--pp-color-surface-default: var(--pp-gray-100);
--pp-color-action-primary: var(--pp-indigo-600);
--pp-space-inset-md: var(--pp-space-4);
}

The scales underneath

The primitive tier is mostly three scales, and each has one decision that matters more than the rest.

For space, the decision is the base unit. Four pixels, expressed in rem, then a ladder that grows faster than linearly so the large steps stay useful: 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4 rem. A linear ladder of twelve equal steps produces sizes nobody uses and forces every layout to pick between two nearly identical values.

For typography, the decision is whether sizes are fixed or fluid. A fluid scale uses clamp() so each step grows between a small and a large viewport without breakpoints, and it is the right default for content sites. Line height is a unitless number, never a length, so it scales with the size it belongs to.

For colour, the decision is the colour space of the primitives, and the answer has changed recently. Perceptual spaces such as OKLCH (lightness, chroma and hue in the OKLab model) give ramps where each step is the same perceived distance from the next, which means a text and surface pair that passes contrast at one hue passes at another. Evil Martians' account of moving to OKLCH covers the why; the practical upshot is that a ramp is generated by holding hue and chroma and stepping lightness, and the numbers are readable by a human. A ramp built in hexadecimal has neither property.

:root {
/* one hue, chroma held, lightness stepped: a ramp you can reason about */
--pp-indigo-100: oklch(94% 0.05 275);
--pp-indigo-300: oklch(80% 0.12 275);
--pp-indigo-500: oklch(60% 0.2 275);
--pp-indigo-700: oklch(42% 0.18 275);
--pp-indigo-900: oklch(26% 0.1 275);
}

The custom-property mechanics you cannot skip

With tiers, names and scales in place, the remaining foundation is how the browser actually treats a custom property, because the theming pattern in the next section depends on four behaviours, and one of them is a trap.

Custom properties inherit, and they are computed where they are used. The Mozilla Developer Network (MDN) guide to custom properties puts it plainly: a two-dash custom property always inherits from its parent, and its value is computed where it is needed rather than stored and reused. This is why declaring tokens on :root makes them available everywhere, and why redefining one on a subtree changes every descendant.

The trap: an alias is resolved where it is declared, not where it is read. When --button-background: var(--color-action-primary) is declared on :root, the browser substitutes the reference at the root and the computed value, a colour, is what descendants inherit. A section that later redefines --color-action-primary does not change the inherited --button-background, because that one was already a colour by the time it arrived. A reference between tokens is resolved at the element that declares it, so any alias you expect a scoped theme to affect must be declared inside the scope, or read directly in the component's rule. This is the single most common reason a scoped dark section shows a light button.

/* Trap */
:root {
--color-action-primary: var(--indigo-600);
--button-background: var(--color-action-primary); /* resolved here, once */
}
.banner[data-theme="dark"] {
--color-action-primary: var(--indigo-300); /* the button never sees this */
}

/* Fix: the component reads the semantic token where it is used */
.button {
background: var(--button-background, var(--color-action-primary));
}

A bad reference does not fall back to the previous declaration. With ordinary CSS, an invalid declaration is dropped and the earlier one wins. With var(), validity is only known at computed-value time, and the specification makes a failed substitution guaranteed invalid: the property becomes unset, which means inherited for inheritable properties and the initial value for the rest. A typo in a token name produces a transparent background, not a red squiggle. The second argument of var() is the fallback, and a design system should use it exactly where an override slot is optional, as in the fix above, and nowhere else.

Registering a token gives it a type, a default, and control over inheritance. The @property rule, Baseline across browsers since 2024, declares a custom property's syntax, whether it inherits, and an initial-value. Registration is what lets a colour token animate, because the browser now knows it holds a colour rather than a string of tokens; it is what makes a typo fall back to a sane initial value instead of unset; and inherits: false is the right setting for a component slot that should never leak into nested components. Register the few tokens that need one of those three things, not the whole set.

@property --button-background {
syntax: "<color>";
inherits: false;
initial-value: transparent;
}

.button {
background: var(--button-background);
transition: background var(--pp-duration) var(--pp-ease); /* now animates */
}

Theming is redefining the semantic tier in a scope

Everything above converges here. A theme is a scope in which the semantic tokens are redefined; the primitives do not change and the components do not know. Light is the root, dark is an attribute, and a themed section is the same attribute on a subtree.

:root,
[data-theme="light"] {
color-scheme: light;
--pp-color-text-default: var(--pp-gray-900);
--pp-color-surface-default: var(--pp-gray-100);
--pp-color-action-primary: var(--pp-indigo-600);
}

[data-theme="dark"] {
color-scheme: dark;
--pp-color-text-default: var(--pp-gray-100);
--pp-color-surface-default: var(--pp-gray-900);
--pp-color-action-primary: var(--pp-indigo-300);
}

The color-scheme line is not decoration. It tells the browser which scheme is active so that form controls, scrollbars and the default canvas follow, and it is what the newer light-dark() function reads. With color-scheme: light dark on the root, a semantic token can carry both values in one declaration, --pp-color-text-default: light-dark(var(--pp-gray-900), var(--pp-gray-100)), and a subtree switches by setting color-scheme: dark alone. It is the cleanest mechanism when a theme is exactly a light and dark pair, and the wrong one the moment a third theme or a non-colour token enters.

MechanismScoped to a subtreeMore than two themesNon-colour tokensFollows the operating-system preference
A media query inside each componentNoNoYesYes
A data-theme attribute redefining semantic tokensYesYesYesWith a line of script
color-scheme plus light-dark()YesNoNoYes

The same attribute mechanism covers every other axis a system grows: a data-density="compact" scope that redefines the space semantic tokens, a data-brand scope that redefines the action colours. Each axis is its own attribute, each redefines only the semantic tokens it owns, and because components read semantic tokens the axes compose without a single component change.

One source, generated outputs

The last of the five is the one that turns a stylesheet into a system. The token decisions live in a file that is not CSS, and the CSS is generated from it, together with anything else that needs the same decisions: a TypeScript object for components that compute styles, a Tailwind theme, documentation, and if the day comes, the native platforms covered in the design-systems post.

The format question is settled now. The Design Tokens Community Group (DTCG) at the World Wide Web Consortium (W3C) published the first stable version of the tokens format in October 2025, with editors from Adobe, Figma, Google, Microsoft, Salesforce and others. In the format, a token is any JavaScript Object Notation (JSON) object with a $value, it carries a $type (or inherits one from its group), it references another token with {group.token}, it may carry a $description and a $deprecated flag, and the file ends in .tokens.json. Colour values may be expressed in any CSS Color 4 space, OKLCH included, which is what makes the perceptual ramps from earlier representable at the source.

{
"color": {
"$type": "color",
"indigo": {
"600": { "$value": { "colorSpace": "oklch", "components": [0.5, 0.2, 275] } }
},
"action": {
"primary": {
"$value": "{color.indigo.600}",
"$description": "Primary interactive colour: buttons, links, focus."
}
}
}
}

Style Dictionary is the usual build step: it reads the token files, resolves the references, and emits a CSS file of custom properties per theme, plus whatever other formats you configure. The generated CSS looks exactly like the hand-written examples above, which is the point: the mechanics do not change, only the place where the decision is made.

Even for a web-only system with one author, the JSON is the contract and the CSS is a build artefact, because the moment two places can each state a token's value, one of them is wrong. One caution, though: the pipeline formalises decisions, it does not make them. Set up the tiers and the grammar in plain CSS first, live with them for a few components, and generate only once the shape has stopped moving.

The eighty percent you can defer

The five foundations are what has to be right on day one. The following are real parts of a mature system that add nothing until a specific need appears, and each is cheap to add later precisely because the foundations do not change when it arrives:

  • Component tokens for every component. Add a slot when something needs overriding from outside; a full set on day one is a second semantic tier with worse names.
  • Design-tool synchronisation. Valuable once designers edit tokens routinely; premature while the token set is still being decided in code.
  • A second brand. The attribute mechanism handles it whenever it comes. Building it before a second brand exists is theming a hypothesis.
  • A full motion vocabulary. One duration pair and one easing curve cover nearly everything; add named curves when a component genuinely needs a different feel.
  • A long z-index ladder. Four named layers (base, raised, overlay, toast) outlast any numeric scheme.
  • Breakpoints as CSS tokens. They cannot be used where breakpoints are used; keep them in the source file for generated code and documentation.

What to build this week

The order matters, because each step is the foundation of the next.

  1. Write the naming grammar down, including the namespace, in a short document that lives with the tokens.
  2. Build the primitives: colour ramps in OKLCH, the spacing ladder, the type scale, radii, two or three shadows, one duration pair and one easing.
  3. Define the semantic tier for text, surface, border and action, with their states, and make it the only tier components import.
  4. Add the dark theme by redefining the semantic tier under an attribute, and set color-scheme with it.
  5. Register with @property the handful of tokens that animate or serve as component slots.
  6. Move the source into a .tokens.json file and generate the CSS from it.
  7. Enforce the one rule: a lint step, or a plain search in continuous integration, that fails when a component references a primitive.

Most of a design system's lifetime is spent adding components on top of this. Those components are the eighty percent of the code and, if the twenty percent underneath them is right, almost none of the trouble.

References

  1. Jina Anne, Salesforce Lightning Design System — jina.me
  2. Nathan Curtis, Naming Tokens in Design Systems — EightShapes (2020)
  3. Andrey Sitnik and Travis Turner, OKLCH in CSS: why we moved from RGB and HSL — Evil Martians
  4. Using CSS custom properties (variables) — MDN Web Docs
  5. CSS Custom Properties for Cascading Variables Module Level 1 — W3C
  6. Una Kravets, @property: Next-gen CSS variables now with universal browser support — web.dev (2024)
  7. light-dark() — MDN Web Docs
  8. Design Tokens specification reaches first stable version — Design Tokens Community Group, W3C (2025)
  9. Design Tokens Format Module — designtokens.org
  10. Style Dictionary — styledictionary.com