Index

Tokens in layers

Writing
Writing

How to build a three-layer token system, from generated scales to every platform.

This is the simplest, most complete token setup I've seen work. It's 3 layers, and each one points at the layer directly below it.

  1. Base: raw values. Every color step, spacing step and radius.
  2. Semantic: decisions. What's a surface, what's text, what's an action. Some teams call these decision tokens.
  3. Component: parts. A button's background, a card's padding.

UI reads component tokens. Component tokens read semantic tokens. Semantic tokens read base tokens.

One value followed up the stack: blue.600 → color.bg.action.default.rest → button.primary.bg.rest → the button.

Files at the end:

tokens/
├── config.js        # hues, chroma, lightness curve (you write)
├── generate.js      # writes base.json (you write once)
├── base.json        # generated, never edited by hand
├── semantic.json    # light theme (you write)
├── semantic.dark.json
├── component.json   # you write
└── build/           # tokens.css, Figma variables, Tokens.swift

The examples use the Design Tokens format: every token is an object with a $value, and {curly.braces} reference another token.

Step 1: set up the base scales

Pick your steps and your lightness curve once. Every hue uses both.

// tokens/config.js
export const steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
export const lightness = [97, 93, 86, 77, 69, 62, 54, 46, 38, 28, 20];
 
export const hues = {
  gray: { hue: 255, chroma: 0.01 },
  blue: { hue: 255, chroma: 0.18 },
  green: { hue: 150, chroma: 0.15 },
  red: { hue: 25, chroma: 0.19 },
};
 
export const space = [1, 2, 3, 4, 6, 8, 12, 16]; // step n = 4n px

Rules:

  • Name by position: blue.600, space.4. Never blue.brand or space.button.
  • Every hue gets the same 11 steps.
  • Use OKLCH. Its lightness is perceptually even, so blue.600 and red.600 have the same contrast against white.
Every hue shares the same 11 steps and the same lightness curve. Swatches show lightness only. Spacing steps are multiples of 4.

Step 2: generate the base layer

Don't type base tokens by hand. Write a script that turns the config into base.json.

// tokens/generate.js
import { writeFileSync } from "node:fs";
import { steps, lightness, hues, space } from "./config.js";
 
function scale({ hue, chroma }) {
  return Object.fromEntries(
    steps.map((step, i) => {
      // chroma peaks at 500 and tapers toward both ends
      const c = chroma * (1 - Math.abs(i - 5) / 7);
      return [step, { $value: `oklch(${lightness[i]}% ${c.toFixed(3)} ${hue})` }];
    }),
  );
}
 
const base = {
  color: {
    white: { $value: "oklch(100% 0 0)" },
    ...Object.fromEntries(Object.entries(hues).map(([name, h]) => [name, scale(h)])),
  },
  space: Object.fromEntries(space.map((n) => [n, { $value: `${n * 4}px` }])),
};
 
writeFileSync("tokens/base.json", JSON.stringify(base, null, 2));

Run it whenever the config changes. To add a hue, add a line to hues and rerun.

Nothing outside the token files should reference a base token directly.

Step 3: define the naming convention

Write the naming convention down before you write a single semantic token. Every name is the same slots in the same order, with each slot filled from a fixed list.

Semantic names on top, component names below. Read left to right, broad to specific.

Semantic tokens use <category>.<property>.<role>.<emphasis>.<state>:

  • Category: the token type. color, space, radius, size, font.
  • Property: what it's applied to. bg, text, icon, border.
  • Role: what it's for. neutral, action, success, warning, danger.
  • Emphasis: how loud it is. default, subtle, strong, inverse.
  • State: interaction. rest, hover, pressed, focus, disabled.

Component tokens use <component>.<variant>.<property>.<state>: button.primary.bg.hover. The property and state lists are the same ones the semantic layer uses.

Non-color tokens drop the slots they don't need and keep the order: space.control.x, radius.control, size.control.md.

Rules:

  • One word per idea. It's bg, everywhere. Never background in one file and surface or fill in another.
  • Fixed lists. A new role or emphasis is a decision. Add it to the list first, then use it.
  • State is all or nothing. Tokens that change on interaction spell out every state, including rest. Tokens that don't change have no state slot.
  • Name the purpose, never the value. No blue, no 600, no light or dark in a semantic or component name.
  • Lowercase, dots between slots, hyphens inside a slot: padding-x.

Step 4: write the semantic layer

Fill in the convention one property at a time, pointing each name at a base step.

{
  "color": {
    "bg": {
      "neutral": {
        "default": { "$value": "{color.white}" },
        "subtle":  { "$value": "{color.gray.50}" }
      },
      "action": {
        "default": {
          "rest":     { "$value": "{color.blue.600}" },
          "hover":    { "$value": "{color.blue.700}" },
          "pressed":  { "$value": "{color.blue.800}" },
          "disabled": { "$value": "{color.gray.200}" }
        }
      }
    },
    "text": {
      "neutral": {
        "default": { "$value": "{color.gray.900}" },
        "subtle":  { "$value": "{color.gray.600}" },
        "inverse": { "$value": "{color.white}" }
      }
    },
    "border": {
      "neutral": {
        "default": { "$value": "{color.gray.200}" }
      }
    }
  }
}

Rules:

  • Every value is a reference. A raw value here is a bug.
  • Aim for 40 to 80 semantic colors. Past 150, some of them are component tokens.
  • If a name won't fit the convention, the convention is missing a word or the token belongs in the component layer.

Step 5: add a dark theme

Copy semantic.json to semantic.dark.json. Keep every name. Change only the references.

{
  "color": {
    "bg": {
      "neutral": {
        "default": { "$value": "{color.gray.950}" },
        "subtle":  { "$value": "{color.gray.900}" }
      },
      "action": {
        "default": {
          "rest":  { "$value": "{color.blue.400}" },
          "hover": { "$value": "{color.blue.300}" }
        }
      }
    },
    "text": {
      "neutral": {
        "default": { "$value": "{color.gray.50}" },
        "subtle":  { "$value": "{color.gray.400}" },
        "inverse": { "$value": "{color.gray.950}" }
      }
    }
  }
}
Same names, different arrows. The color. prefix is left off.

Components only read semantic names, so they pick up the dark theme without changes. Any extra theme (high contrast, a second brand) is another file built the same way.

Step 6: add component tokens

Give a component its own token for each part that might need to differ from the semantic default. Name it with <component>.<variant>.<property>.<state> and point it at a semantic token.

{
  "button": {
    "primary": {
      "bg": {
        "rest":  { "$value": "{color.bg.action.default.rest}" },
        "hover": { "$value": "{color.bg.action.default.hover}" }
      },
      "text": { "$value": "{color.text.neutral.inverse}" }
    },
    "radius":    { "$value": "{radius.control}" },
    "padding-x": { "$value": "{space.control.x}" },
    "height":    { "$value": "{size.control.md}" }
  }
}
Each labelled part is a button token.

Each part resolves down the stack, component to semantic to base:

  • button.primary.bg.rest → color.bg.action.default.rest → blue.600
  • button.primary.bg.hover → color.bg.action.default.hover → blue.700
  • button.primary.text → color.text.neutral.inverse → white
  • button.radius → radius.control → radius.2 (8)
  • button.padding-x → space.control.x → space.4 (16)
  • button.height → size.control.md → size.10 (40)

Rules:

  • Point at semantic tokens, never base. A component token on blue.600 won't change in dark mode.
  • Variant-specific parts sit under the variant (button.primary.bg). Parts every variant shares sit directly under the component (button.radius).
  • Skip parts that will always match the semantic default. A 1px border every input shares doesn't need input.border-width.

Step 7: lint names and references

Check the convention and the layer rule in a script, so mistakes fail the build instead of a review.

// tokens/lint.js
const allowed = { semantic: "base", component: "semantic" };
const slots = {
  property: ["bg", "text", "icon", "border"],
  role: ["neutral", "action", "success", "warning", "danger"],
  emphasis: ["default", "subtle", "strong", "inverse"],
  state: ["rest", "hover", "pressed", "focus", "disabled"],
};
 
for (const path of colorNames(semantic)) {
  const [, property, role, emphasis, state] = path.split(".");
  if (!slots.property.includes(property)) fail(`${path}: unknown property "${property}"`);
  if (!slots.role.includes(role)) fail(`${path}: unknown role "${role}"`);
  if (!slots.emphasis.includes(emphasis)) fail(`${path}: unknown emphasis "${emphasis}"`);
  if (state && !slots.state.includes(state)) fail(`${path}: unknown state "${state}"`);
}
 
for (const [layer, tokens] of Object.entries({ semantic, component })) {
  for (const { path, ref } of references(tokens)) {
    const target = layerOf(ref);
    if (!target) fail(`${path} → ${ref}: token doesn't exist`);
    if (target !== allowed[layer]) fail(`${path} → ${ref}: ${layer} must point at ${allowed[layer]}`);
  }
}

Also check:

  • Contrast: every color.text.* against every color.bg.* it's paired with, in every theme.
  • Orphans: base and semantic tokens nothing references.

Step 8: build every platform

Merge the files, resolve every reference, and write one output per platform. Style Dictionary does this, or a short script will.

Filled boxes are generated. Outlined boxes are written by people.

For web, write semantic and component tokens as CSS custom properties, one block per theme:

:root {
  --color-bg-action-default-rest: oklch(54% 0.18 255);
  --button-primary-bg-rest: var(--color-bg-action-default-rest);
}
 
.dark {
  --color-bg-action-default-rest: oklch(69% 0.154 255);
}

Keep the var() references in the output. That way a theme only redefines the semantic layer, and component tokens follow.

For Figma, write the same tokens as variables, with one mode per theme. For native, write constants.

Making changes

  • Brand color: change its entry in hues, then generate and build.
  • New hue: add it to hues, then generate and build.
  • New theme: add a semantic.<theme>.json, then build.
  • What a text or surface color means: edit semantic.json, then build.
  • A new role, emphasis or state: add it to the convention and the lint lists first, then use it.
  • One component's look: edit component.json, then build.

If you can't tell which file a change belongs in, it's probably a missing semantic token. Name it with the convention, add it to semantic.json, then point the component at it.