Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close