Design Tokens
Named values in three layers — primitive, semantic, component. The semantic layer is the one that makes a re-theme a set of value swaps instead of a rewrite.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How should design values be named and layered so that a theme, a dark mode or a rebrand is a change to values rather than a change to every component?
A person turns on dark mode, or opens the product in a high-contrast setting, or works for a customer whose brand colour is not yours. They expect the same product, legible, with nothing left behind in the old palette.
Put the palette in variables — --blue-500, --gray-100, --space-4 — and use them everywhere instead of hardcoding hex values. That is what tokens are.
Dark mode arrives and --blue-500 must become a different blue in one theme, but the name says what it *is*, not what it is *for*, so every usage must be inspected to decide whether it should change.
- Dark mode arrives and
--blue-500must become a different blue in one theme, but the name says what it *is*, not what it is *for*, so every usage must be inspected to decide whether it should change. --gray-100is a page background in one place, a disabled label in another and a divider in a third. In dark mode those three want to move in different directions, and one variable cannot.- A component hardcodes
--blue-500for its focus ring. The rebrand changes the brand blue, and now the focus ring is brand-coloured on a brand-coloured surface, with a contrast failure nobody wrote a test for. - Spacing tokens named after values —
--space-16meaning sixteen pixels — become lies the moment the scale is retuned, and renaming them is a change to every file. - Two teams add
--color-warning-bgand--bg-warningfor the same concept, both are used, and neither can be removed. - Theme switching is implemented by loading a second stylesheet, so switching flashes the old theme and the choice does not survive a reload without a script that runs before first paint.
What is actually happening
In the browser, not in the framework.
- Tokens are named values with a layer, and the layer is what makes them work. Primitive tokens name raw values:
--blue-500,--gray-100,--space-4,--radius-2. They describe what something is and carry no opinion about use. - Semantic tokens name roles:
--surface,--surface-raised,--text-primary,--text-muted,--border-subtle,--focus-ring,--danger. They point at primitives, and they are the only layer components are allowed to consume. - Component tokens name a component's own knobs where a component genuinely needs one:
--button-padding-inline,--field-border. They point at semantic tokens and give consumers a supported override point. - A theme is a re-pointing of the semantic layer. Dark mode does not add a component; it says
--surfacenow resolves to a dark primitive and--text-primaryto a light one. If components consumed primitives directly, there is nothing to re-point (Custom Properties). - On the web, CSS custom properties are the natural delivery mechanism because they inherit and are resolved at computed-value time. Change one on an ancestor and every descendant that inherits it recomputes, without touching the DOM (Inheritance and Computed Style).
- Tokens are usually authored once in a neutral format and generated into per-platform outputs: CSS custom properties for the web, and whatever the native platforms need. The single source is what keeps web and native from drifting.
- Contrast is a property of a pair, not of a colour. Semantic tokens should be defined as pairs — a surface and the text intended to sit on it — so that every theme can be validated pair by pair (Contrast, Colour and Motion).
What this makes the browser do
And which of it is avoidable.
- Custom properties are inherited, so changing one on
:rootinvalidates computed style for every element that inherits it. That is style recalculation across the document — real work, but no layout unless the property feeds a geometric value (Style Invalidation). - A theme switch that changes only colour and shadow tokens costs style and paint. One that changes spacing or font-size tokens costs layout as well, because the geometry actually changed (The Cost of a Change).
- Custom properties are resolved at computed-value time and cannot be transitioned as raw values in the way a registered typed property can, so animating a token is not the same as animating the property it feeds.
- Very deep custom-property chains — a component token pointing at a semantic token pointing at a primitive — are resolved per element per recalculation. It is cheap per lookup and it is not free at scale (Selector Matching Cost).
- Shipping every theme's values in one stylesheet costs bytes on every load; shipping them as separate files costs a request and risks a flash at switch time (Render-Blocking Resources).
The layer that makes re-theming possible
Almost every token system that fails does so in the same way: it has primitives and it has components, and nothing in between. The names describe values, the components consume them directly, and the system works perfectly until somebody asks for a second theme.
The semantic layer is a small amount of indirection that buys one specific capability: the ability to change what a role resolves to without touching anything that uses the role. Once it exists, dark mode is a block of value assignments. Without it, dark mode is a review of every declaration in the codebase, by hand, deciding one at a time whether this particular grey was a background or a border.
1:root {2 /* 1. primitive — what it IS. Never consumed by a component. */3 --blue-500: #1f6feb;4 --blue-300: #6ea8ff;5 --gray-950: #0b0d10;6 --gray-100: #f1f3f5;7 --gray-500: #6b7280;8 9 /* 2. semantic — what it is FOR. The only layer components may use. */10 --surface: var(--gray-100);11 --text-primary: var(--gray-950);12 --text-muted: var(--gray-500);13 --accent: var(--blue-500);14 --focus-ring: var(--blue-500); /* its own token: it is not "the accent" */15}16 17/* A theme re-points the semantic layer. Nothing below it changes. */18:root[data-theme="dark"] {19 --surface: var(--gray-950);20 --text-primary: var(--gray-100);21 --text-muted: #9aa4b2; /* re-tuned, not inverted */22 --accent: var(--blue-300);23 --focus-ring: var(--blue-300);24}25 26/* 3. component tokens: a named, supported override point. */27.ds-button {28 --button-bg: var(--accent);29 --button-fg: var(--surface);30 background: var(--button-bg);31 color: var(--button-fg);32}33 34/* The rule that makes all of it work, and the one that gets broken: */35.ds-button:focus-visible { outline: 2px solid var(--focus-ring); } /* correct */36/* .ds-button:focus-visible { outline: 2px solid var(--blue-500); } breaks dark mode silently */The focus ring is the clearest case for a role-named token. It is visually similar to the accent in the light theme, so reaching for --accent looks harmless — until a theme puts the accent colour behind the focused element and the indicator disappears for exactly the users who depend on it.
Naming: the test is whether the name survives the value
A token name is a promise about what will still be true after the value changes. --blue-500 promises the colour is blue, which the next brand refresh may break. --space-16 promises sixteen of something, which a retuned scale breaks. --text-muted and --space-md promise a role and a position in a scale, both of which survive.
The second half of naming is consolidation. Token sets grow by addition — someone needs a slightly different border and adds one — and they only shrink deliberately. A semantic layer that a developer cannot hold in their head is one that gets bypassed, so periodically merging near-duplicates is part of maintaining the system rather than a cleanup task.
PRIMITIVE SEMANTIC COMPONENT
what it is what it is for one component's knob
-----------------------------------------------------------------
--blue-500 -> --accent -> --button-bg
--gray-100 -> --surface -> --card-bg
--gray-500 -> --text-muted -> --field-hint-color
--red-600 -> --danger -> --toast-error-border
--space-4 -> --space-md -> --button-padding-inline
--dur-150 -> --dur-quick -> --tooltip-delay
names that fail the test why
-----------------------------------------------------------------
--blue-500 used in a component rebrand cannot reach it
--gray-100 as background AND border one value, two roles that
must diverge in dark mode
--space-16 (value in the name) a retuned scale makes the
name a lie in every file
--color-warning-bg + --bg-warning same role, two names, both
used, neither removable
--accent used for the focus ring indicator vanishes on any
theme where accent is a
surface colourWhat a theme switch costs the browser
Runtime theming is feasible because custom properties inherit and are resolved when computed style is calculated. Setting one attribute on the root element invalidates the computed style of everything that inherits the changed properties, and the browser recomputes from there — no DOM mutation, no stylesheet swap, no reload.
What that costs depends entirely on what the token feeds. A colour token feeds paint. A spacing or font-size token feeds geometry, so it feeds layout as well, and on a large document that is a very different amount of work for what looks to the user like the same action.
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| Toggle `data-theme` on the root, colour tokens only | yes | no | yes | yes | Custom properties inherit, so computed style is invalidated for every element that inherits them; geometry is untouched, so the browser repaints without re-measuring (Style Invalidation). |
| Change a spacing or type-scale token | yes | yes | yes | yes | The token feeds a geometric property, so boxes actually change size and the layout pass has to run over everything affected (Layout Thrashing). |
| Change a component token on one element | yes | maybe | maybe | maybe | Scoped to that element's subtree — but whether it costs layout depends on which property the token feeds, and whether it costs paint depends on whether the element is visible at all. |
| Change a motion-duration token | yes | no | no | maybe | Nothing repaints from the change itself; it alters the duration of animations that start afterwards, and only touches the compositor if one of those animations is compositor-driven (Cheap and Expensive Animation). |
| Swap a whole stylesheet instead of re-pointing tokens | yes | maybe | yes | yes | The new sheet must be fetched and parsed before it applies, so there is a window showing the old theme — the flash that re-pointing custom properties avoids entirely (Render-Blocking Resources). |
caveat Every maybe here depends on what the token actually feeds and on what else is on the page: the same token change is free on an offscreen subtree with containment applied and expensive on a large visible list, and only a profile of your own page settles it (CSS Containment).
How to build it
Most important first.
- Name semantic tokens after the role, never the value.
--text-primary, not--gray-900. The test is whether the name still makes sense after the value changes;--blue-500fails it and--brand-accentpasses. - Forbid components from consuming primitives. That single rule is what makes a re-theme possible, and it is worth enforcing with a lint rule rather than a convention, because every violation is silent until the rebrand.
- Define colour tokens as pairs with their intended foreground, and validate contrast for every pair in every theme as part of the build. A theme that ships an unreadable pair is a bug, not a design preference (Contrast, Colour and Motion).
- Deliver themes as custom properties on a root attribute —
[data-theme="dark"]— so switching is one attribute change and the cascade does the rest, with no stylesheet swap and no flash (Custom Properties). - Respect the system preference by default and let an explicit user choice override it, then persist the choice and apply it before first paint. A theme that flashes on every load is worse than not offering one (Persistent Client State).
- Token the motion durations and easings too, and make the reduced-motion preference resolve them to zero at the token layer, so component authors cannot forget it (Contrast, Colour and Motion).
- Version the token names as seriously as any API. A renamed token is a breaking change for every consumer, so rename by adding an alias, migrating, then removing (Design Systems).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Contrast is the token layer's most direct accessibility responsibility, and because it is a property of a pair, only pairs can be validated. A palette where every colour is individually approved can still produce an unreadable combination (Contrast, Colour and Motion).
- The focus ring must be its own semantic token with its own contrast requirement against every surface it can appear on. Focus indication that inherits the brand colour disappears the moment the brand colour becomes a background (Keyboard Operability).
- Users can override colours entirely — forced-colours modes and user stylesheets exist and are used. Tokens should degrade to system colours rather than fight them, and a design that depends on colour alone to carry meaning fails in that mode regardless of tokens.
- Motion tokens are where reduced-motion is honoured once for everyone. Resolving duration tokens to zero under the preference is more reliable than asking every animation author to remember (Contrast, Colour and Motion).
- Type-scale and spacing tokens have to survive user zoom and larger default font sizes. Tokens expressed in relative units scale with the user's setting; tokens frozen in pixels do not (Responsive Typography).
What can go wrong
- Semantic tokens invented ad hoc, so the set grows faster than it is consolidated and two names mean the same thing in different products.
- A theme that covers the design system's components but not the application code around them, so a dark-mode page has light-mode panels wherever a team wrote its own CSS.
- Contrast validated on the light theme only, because that is the one designers worked in, so the dark theme ships with unreadable muted text.
- The unthemed island: an SVG with a hardcoded fill, an image with a baked-in white background, a third-party embed that has no theme. These are the ones users notice first.
- Theme flash on load, because the theme is applied by a script that runs after the first paint. It is a small bug that reads as low quality on every single visit.
- The mitigation failing: a lint rule that forbids primitives in components, worked around with a wrapper variable that points at a primitive anyway.
- The theme script and first paint: unless the theme is applied synchronously before the first render, the browser paints the default theme and then repaints, which the user sees as a flash (Render-Blocking Resources).
- The system preference changing while the page is open, racing an in-flight user choice. The explicit choice must win, and it must not be silently overwritten by a preference-change listener.
- Theme state across tabs: a change in one tab and a read in another can interleave, so tabs should react to the storage change rather than assume they are alone (Auth Across Tabs).
- Tokens are values, not behaviour, so the direct security surface is small — but a token file generated from a design tool at build time is code generation from an external source, and it belongs in review like any other generated artifact (Artifact and Build Integrity in Security).
- Custom properties can be set from JavaScript, so a theming feature that accepts user-supplied values must validate them; a custom property injected into a
url()or an unsanitised style attribute is a real injection surface (Cross-Site Scripting). - A per-tenant theme served from user-controlled data is user input rendering as CSS. Validate against an allowlist of tokens and value shapes rather than passing strings through (Parse, Validate, Authorize, Process in Security).
- Persisted theme choice in browser storage is low-sensitivity but not nothing: it is a stable, readable signal about the user that any script on the page can read (Storage Security and Durability).
- "Tokens are just CSS variables." The delivery mechanism is CSS variables. The token system is the naming and the layering; variables with primitive names are the problem this lesson describes, not the solution.
- "More tokens is better." A token set nobody can navigate gets bypassed. The semantic layer should be small enough that a developer can recall it, and it should grow by consolidation as often as by addition.
- "Dark mode is inverting the palette." Inverting produces harsh, over-contrasted surfaces and destroys elevation, because shadow does not invert. Dark themes re-point tokens and usually re-tune elevation as surface lightness instead.
- "A component token is a semantic token." Component tokens are a supported override point for one component. Using them as the shared vocabulary produces a token per component per property and no shared meaning at all.
- "Once tokens exist, theming is free." Theming is free for everything that consumes them. The work is finding everything that does not: the SVGs, the embeds, the emails, the one legacy page (Design Systems).
Measuring it, and what changes in the field
- Grep the codebase for raw colour literals and for primitive tokens used outside the semantic layer. The count is the honest measure of whether a re-theme will work, and it should trend to zero.
- Automated contrast checks across every semantic pair in every theme, run in CI. This is one of the few accessibility properties a machine can fully verify (Accessibility Testing).
- Visual regression across themes on a representative page catches the unthemed islands — the SVG, the embed, the one panel with its own CSS (Visual Regression Testing).
- In the Elements panel, the computed value of a custom property on an element shows the whole resolution chain, which is how you find the component that is pointing at a primitive (A Mental Model of the Devtools).
- The Performance panel shows what a theme switch actually cost: a style recalculation and a paint, or a full layout because a spacing token moved (Debugging Rendering and Jank).
- On a slow device, a theme switch that triggers layout across a large DOM is visible as a stutter; one that triggers only style and paint usually is not (The Frame Budget).
- With many themes — per-tenant branding, light, dark, high contrast — shipping all of them in one stylesheet grows every page load, and generating them per tenant moves the cost to the build (Content-Hashed Assets).
- On first load, the theme must be resolved before first paint or the user sees a flash. That is a delivery constraint, not a styling one (The Critical Rendering Path).
- In a long-lived tab, the system preference can change underneath the application — a scheduled dark mode at sunset — and a page that only read the preference at startup will not follow it.
- Three layers is more indirection than two, and a new contributor has to learn where a value belongs before adding one. That indirection is exactly what a re-theme spends, and there is no way to have the second without the first.
- A strict semantic layer will occasionally not have the token a screen needs, and the honest answer is to add a semantic token rather than reach for a primitive — which is slower, and is the discipline the whole system rests on.
- Custom properties are dynamic and inherited, which is what makes runtime theming work and also means the resolved value of a token depends on where in the tree an element sits. That is a debugging cost you pay in exchange for the capability.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe primitive-semantic-component layering is a naming architecture rather than a web technique, and it applies identically to a native platform's theme resources or a design tool's variable collections; only the delivery mechanism changes, and only the web gets inheritance and runtime resolution for free.
- GENERALCSS custom property inheritance and computed-value-time resolution are specified behaviour and consistent across Blink, Gecko and WebKit; what differs is tooling support for registered typed properties, which affects whether a token can be animated rather than whether it can be themed.
- PLATFORM-SPECIFICForced-colours and high-contrast modes are provided by the operating system and override author colours wholesale, so a token system must degrade to system colours in those modes rather than assume its palette is what the user sees — and the behaviour differs between Windows high contrast and other platforms' equivalents.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — the semantic layer is an interface between the people who choose values and the people who consume meanings, and the reason it works is the same reason any indirection works: it lets one side change without the other being rewritten.