Lesson 06 / 19
Design Tokens
Splitting tokens into primitive, semantic, and component layers, setting rules for cross-layer references, and resolving the token graph for cycles, undefined references, layer violations, and orphaned tokens.
Contents
The system’s founding decisions are complete: the scope is drawn, the scales are chosen, the design language is auditable. So far, every one of these decisions still lives as prose. An interface cannot read prose; it reads named values.
A design token is the machine-readable, named counterpart of a design decision. The What Is a Design System lesson showed that naming reduced the number of change sites by a factor of 3.88; this lesson establishes how those names are organized. The problem is not keeping a single list — a single list eventually turns into a dictionary where it becomes unclear which name was set for which purpose. Tokens are layered, and cross-layer references are bound to a rule.
Three Layers, Two Rules
The primitive layer carries raw values: color steps, scale steps, size steps. A
primitive token is a number or a color; it carries no purpose. neutral-500 is a shade
of gray and says nothing about where it will be used.
The semantic layer names a job and binds it to a primitive token. border is a job;
which gray it is comes second. The Color System lesson established this distinction for
color; token architecture establishes the same distinction for spacing, size, and radius.
The component layer opens the setting points of a single component and binds them to
a semantic token. button-surface is a setting point. The Custom Properties lesson
called the names a component exposes outward its style interface; component tokens
are that interface’s system-level counterpart.
References across layers follow two rules. The primitive layer references no token at all; it carries only a fixed value. The component layer references only the semantic layer; referencing a primitive directly is forbidden. The semantic layer may reference a primitive or another semantic token.
The second rule’s rationale was given in the Color System lesson: the moment a component
writes --neutral-600, the system is punctured, because that component is no longer
affected by role reassignment or a theme change. Once the rule is written down, a
violation becomes auditable.
// token.mjs — resolving and auditing a three-layer token graph // Layer rules: // primitive -> carries only a fixed value, cannot reference another token // semantic -> references a primitive or semantic token // component -> references only a semantic token const ALLOWED = { primitive: [], semantic: ["primitive", "semantic"], component: ["semantic"] }; const TOKENS = { // --- primitive layer --- "neutral-000": { layer: "primitive", value: "#ffffff" }, "neutral-500": { layer: "primitive", value: "#757e8a" }, "neutral-600": { layer: "primitive", value: "#5e656e" }, "neutral-900": { layer: "primitive", value: "#212327" }, "primary-100": { layer: "primitive", value: "#dee9f7" }, "primary-600": { layer: "primitive", value: "#275ea5" }, "primary-800": { layer: "primitive", value: "#15335b" }, "spacing-1": { layer: "primitive", value: "4px" }, "spacing-2": { layer: "primitive", value: "8px" }, "spacing-3": { layer: "primitive", value: "12px" }, "spacing-4": { layer: "primitive", value: "16px" }, "spacing-6": { layer: "primitive", value: "24px" }, "step-0": { layer: "primitive", value: "16px" }, "step-1": { layer: "primitive", value: "20px" }, "radius-1": { layer: "primitive", value: "4px" }, // --- semantic layer --- surface: { layer: "semantic", value: "{neutral-000}" }, "text-primary": { layer: "semantic", value: "{neutral-900}" }, "text-secondary": { layer: "semantic", value: "{neutral-600}" }, border: { layer: "semantic", value: "{neutral-500}" }, "action-primary": { layer: "semantic", value: "{primary-600}" }, "action-primary-on": { layer: "semantic", value: "{neutral-000}" }, "accent-pale": { layer: "semantic", value: "{primary-100}" }, "accent-pale-on": { layer: "semantic", value: "{primary-800}" }, "spacing-within-group": { layer: "semantic", value: "{spacing-2}" }, "spacing-between-block": { layer: "semantic", value: "{spacing-4}" }, "spacing-between-section": { layer: "semantic", value: "{spacing-6}" }, "body-size": { layer: "semantic", value: "{step-0}" }, "title-size": { layer: "semantic", value: "{step-1}" }, "radius-default": { layer: "semantic", value: "{radius-1}" }, "warning-ground": { layer: "semantic", value: "{warning-border}" }, // cycle "warning-border": { layer: "semantic", value: "{warning-ground}" }, // cycle "spacing-tight": { layer: "semantic", value: "{spacing-1}" }, // referenced from nowhere // --- component layer --- "button-surface": { layer: "component", value: "{action-primary}" }, "button-text": { layer: "component", value: "{action-primary-on}" }, "button-padding-y": { layer: "component", value: "{spacing-within-group}" }, "button-padding-x": { layer: "component", value: "{spacing-between-block}" }, "button-radius": { layer: "component", value: "{radius-default}" }, "card-surface": { layer: "component", value: "{surface}" }, "card-border": { layer: "component", value: "{neutral-500}" }, // layer violation: directly to a primitive "card-padding": { layer: "component", value: "{spacing-between-block}" }, "record-title-size": { layer: "component", value: "{title-size}" }, "record-metadata-color": { layer: "component", value: "{text-tertiary}" }, // undefined reference "label-surface": { layer: "component", value: "{accent-pale}" }, "label-text": { layer: "component", value: "{accent-pale-on}" }, }; const REFERENCE = /^\{([\w-]+)\}$/; const target = (name) => { const m = TOKENS[name].value.match(REFERENCE); return m ? m[1] : null; }; // --- resolution -------------------------------------------------------------- function resolve(name) { const visited = new Set(); let current = name; const chain = [name]; while (true) { if (!(current in TOKENS)) return { value: null, reason: `undefined: ${current}`, chain }; if (visited.has(current)) return { value: null, reason: "cycle", chain }; visited.add(current); const h = target(current); if (h === null) return { value: TOKENS[current].value, reason: null, chain }; current = h; chain.push(current); } } // --- audits -------------------------------------------------------------------- const broken = []; const layerViolations = []; for (const name of Object.keys(TOKENS)) { const c = resolve(name); if (c.reason) broken.push({ name, ...c }); const h = target(name); if (h === null) continue; const own = TOKENS[name].layer; const targetLayer = h in TOKENS ? TOKENS[h].layer : null; if (targetLayer && !ALLOWED[own].includes(targetLayer)) { layerViolations.push({ name, own, h, targetLayer }); } } // Tokens read directly in component style files. Not every semantic token needs a // component-token counterpart; the component layer is built only where a setting // point is opened. const DIRECT_USE = new Set([ "text-primary", "text-secondary", "border", "body-size", "spacing-between-section", "spacing-within-group", "surface", ]); // Incoming count: how many tokens reference a given token? const incoming = Object.fromEntries(Object.keys(TOKENS).map((a) => [a, 0])); for (const name of Object.keys(TOKENS)) { const h = target(name); if (h && h in incoming) incoming[h]++; } // The component layer counts as leaves; a token in the primitive or semantic layer // with no incoming reference is orphaned. const orphaned = Object.keys(TOKENS).filter( (a) => TOKENS[a].layer !== "component" && incoming[a] === 0 && !DIRECT_USE.has(a) ); const counts = { primitive: 0, semantic: 0, component: 0 }; for (const a of Object.keys(TOKENS)) counts[TOKENS[a].layer]++; console.log("layer token count"); for (const k of ["primitive", "semantic", "component"]) console.log(`${k.padEnd(10)} ${String(counts[k]).padStart(15)}`); console.log(`total ${String(Object.keys(TOKENS).length).padStart(15)}`); console.log("\ncomponent token chain value"); for (const name of Object.keys(TOKENS).filter((a) => TOKENS[a].layer === "component")) { const c = resolve(name); console.log( `${name.padEnd(24)} ${c.chain.join(" > ").padEnd(54)} ${c.value === null ? "UNRESOLVED (" + c.reason + ")" : c.value}` ); } console.log("\nunresolved tokens"); for (const k of broken) console.log(` ${k.name.padEnd(24)} ${k.reason.padEnd(26)} chain: ${k.chain.join(" > ")}`); console.log("\nlayer violations"); for (const i of layerViolations) { console.log(` ${i.name} (${i.own}) -> ${i.h} (${i.targetLayer}); allowed: ${ALLOWED[i.own].join(", ")}`); } console.log("\norphaned tokens (no reference from a token or a style file)"); for (const a of orphaned) console.log(` ${a.padEnd(24)} layer: ${TOKENS[a].layer}`); // --- chain length distribution ------------------------------------------------ const lengths = {}; for (const name of Object.keys(TOKENS)) { const c = resolve(name); if (c.reason) continue; lengths[c.chain.length] = (lengths[c.chain.length] || 0) + 1; } console.log("\nchain length token count"); for (const u of Object.keys(lengths).sort((a, b) => a - b)) { console.log(`${u.padStart(15)} ${String(lengths[u]).padStart(16)}`); } // --- layer transitions --------------------------------------------------------- const transitions = {}; for (const name of Object.keys(TOKENS)) { const h = target(name); if (!h || !(h in TOKENS)) continue; const key = `${TOKENS[name].layer} -> ${TOKENS[h].layer}`; transitions[key] = (transitions[key] || 0) + 1; } console.log("\nlayer transition references allowed"); for (const [g, n] of Object.entries(transitions).sort()) { const [source, dest] = g.split(" -> "); console.log(`${g.padEnd(23)} ${String(n).padStart(7)} ${ALLOWED[source].includes(dest) ? "yes" : "NO"}`); }
layer token count
primitive 15
semantic 17
component 12
total 44
component token chain value
button-surface button-surface > action-primary > primary-600 #275ea5
button-text button-text > action-primary-on > neutral-000 #ffffff
button-padding-y button-padding-y > spacing-within-group > spacing-2 8px
button-padding-x button-padding-x > spacing-between-block > spacing-4 16px
button-radius button-radius > radius-default > radius-1 4px
card-surface card-surface > surface > neutral-000 #ffffff
card-border card-border > neutral-500 #757e8a
card-padding card-padding > spacing-between-block > spacing-4 16px
record-title-size record-title-size > title-size > step-1 20px
record-metadata-color record-metadata-color > text-tertiary UNRESOLVED (undefined: text-tertiary)
label-surface label-surface > accent-pale > primary-100 #dee9f7
label-text label-text > accent-pale-on > primary-800 #15335b
unresolved tokens
warning-ground cycle chain: warning-ground > warning-border > warning-ground
warning-border cycle chain: warning-border > warning-ground > warning-border
record-metadata-color undefined: text-tertiary chain: record-metadata-color > text-tertiary
layer violations
card-border (component) -> neutral-500 (primitive); allowed: semantic
orphaned tokens (no reference from a token or a style file)
spacing-3 layer: primitive
spacing-tight layer: semantic
chain length token count
1 15
2 16
3 10
layer transition references allowed
component -> primitive 1 NO
component -> semantic 10 yes
semantic -> primitive 15 yes
semantic -> semantic 2 yes
The Graph Produces Four Kinds of Defect
A token set is a graph: the nodes are names, the edges are references. The moment it is seen as a graph, it becomes possible to audit, and four distinct kinds of defect surface.
Cycle. warning-ground and warning-border reference each other. Neither produces a
value. The Custom Properties lesson showed the same behavior at the CSS level: two custom
properties that reference each other are treated as invalid. The difference is that the
result there surfaces at runtime; the audit here runs before publication.
Undefined reference. The record-metadata-color token references the name
text-tertiary, and no such token exists. This usually comes from a name being renamed
later or a token being left in draft. When the audit does not run, the component silently
falls back to the wrong color, because an undefined custom property produces invalidity
at computed-value time and the color is inherited from the parent element.
Layer violation. card-border sits in the component layer but references the
primitive token neutral-500 directly. The result produces a correct color — #757e8a
shows up in the output — and that is exactly what makes this defect dangerous. Because the
value is correct, it gives no visual sign. Its effect surfaces only when the theme
changes: the card border stays at the light theme’s gray in the dark theme, because it
skipped the role layer.
Orphaned token. spacing-3 and spacing-tight receive no reference from either a
token or a style file. These look harmless, but they make the system harder to read: the
more unused names a list carries, the less clear it is to whoever is choosing among them
which one to pick. An orphaned token is either removed or its reason for staying is
written down.
Not Every Semantic Token Needs a Component Token
The orphaned-token audit requires a distinction, and that distinction is an architectural decision.
Semantic tokens such as text-primary, border, and body-size have no component-token
counterpart at all. They are read directly in style files. The code knows them through
the DIRECT_USE set, and they do not count as orphaned.
The distinction is this: the component layer is not built for every semantic token,
only where a setting point is opened. button-surface exists because the button’s
variants change its surface. record-title-size exists because the record card’s dense
and sparse versions choose a different title size. Body text’s color, by contrast, is
never adjusted in any component; opening a component token for it would produce a setting
point that never gets used.
The layer-transition table counts this balance: 10 component tokens reference the semantic layer, 15 semantic tokens reference the primitive layer. The component layer is smaller than the semantic layer, and that is how it should be. If the component layer ever reaches the same size as the semantic layer, either every role has been wrapped unnecessarily, or the semantic layer was built incompletely.
Chain Length Is a Trade-off
The chain-length distribution shows three values: 15 tokens carry a value directly, 16 resolve one step later, 10 resolve two steps later. The graph’s depth is capped at three.
This is not a coincidence; it is the direct consequence of the layer count. In a three-layer system, the longest chain is component → semantic → primitive. The semantic layer referencing within itself stretches this limit — the output has two semantic-to-semantic references — and every stretch carries a cost.
The cost is traceability. Understanding why a component ended up with that color means following the whole chain. A three-step chain is readable; in a six-step chain it becomes unclear which step needs to change. A long chain also signals something: if the same role has been wrapped more than once, one of the intermediate steps is unnecessary.
Flattening the chain entirely, however, is just as wrong. A system where component tokens bind directly to primitive values is the layer violation generalized, and it makes theme switching impossible. The right target is not the shortest chain, but the shortest chain within the rule.
Written Form and Naming Convention
A token’s counterpart in style is a custom property. The layer distinction is made
visible through a name prefix; the --card- prefix convention from the Custom Properties
lesson expands here to three layers.
:root { /* primitive */ --neutral-500: #757e8a; --primary-600: #275ea5; --spacing-2: 8px; --spacing-4: 16px; /* semantic */ --border: var(--neutral-500); --action-primary: var(--primary-600); --spacing-within-group: var(--spacing-2); --spacing-between-block: var(--spacing-4); } .button { /* component: references only semantic names */ --button-surface: var(--action-primary); --button-padding-y: var(--spacing-within-group); --button-padding-x: var(--spacing-between-block); background-color: var(--button-surface); padding: var(--button-padding-y) var(--button-padding-x); }
Declaring component tokens on the component’s own rule, not on the root element, is a
scope decision. When the declaration stays on the component, the name is valid only
within that subtree, and the root element does not turn into a list carrying every
component’s setting points. The moment-of-computation rule from the Custom Properties
lesson applies here too: --button-surface is computed on the button element, so if
--action-primary is redefined on an ancestor of the button, the button inherits it.
The last part of the naming convention is how the primitive layer is named. Primitive
names carry no role, only position: neutral-500, spacing-2, step-1. The Typographic
Scale lesson showed that a step number is more durable than a meaning-bearing name; the
same reasoning holds for the whole primitive layer. The only layer that carries meaning
is the semantic layer, and its name already says so.
Summary
- A design token is the machine-readable, named counterpart of a design decision; tokens are organized not as a single list but as a layered graph.
- There are three layers: the primitive carries the raw value, the semantic carries the job, the component layer carries the setting point. The primitive layer makes no reference; the component layer may reference only the semantic layer.
- The graph produces four kinds of defect: cycles, undefined references, layer violations, and orphaned tokens. The layer violation is the most dangerous, because it produces a correct value and surfaces only when the theme changes.
- The component layer is not built for every semantic token, only where a setting point is opened; the component layer is expected to stay smaller than the semantic layer.
- Chain length is a consequence of the layer count; a long chain reduces traceability, and flattening it entirely makes theme switching impossible. The target is the shortest chain within the rule.
- Primitive names carry position, only the semantic layer carries meaning; component tokens are declared on the component’s own rule, not on the root element.
Next Step
With the layer architecture in place, the next step is filling the layers in, and color is the area that carries the most constraints in this work. A color token is not just a name and a value: which ground it can be used on, which threshold it has to clear, and which derivative it switches to in which state are all part of the definition. The next lesson writes color roles as a contract, states which foreground can fall on which ground, and checks that statement automatically with the contrast ratio.
To keep your progress and take notes, Log in
My notes
Log in to take notes.