---
title: 'Design Tokens'
source: 'https://academia.sh/en/courses/design-systems/design-tokens'
course: 'Design Systems'
language: en
updated: '2026-08-19T05:19:55+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```css
: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.
