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.
- Base: raw values. Every color step, spacing step and radius.
- Semantic: decisions. What's a surface, what's text, what's an action. Some teams call these decision tokens.
- Component: parts. A button's background, a card's padding.
UI reads component tokens. Component tokens read semantic tokens. Semantic tokens read base tokens.
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.swiftThe 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 pxRules:
- Name by position:
blue.600,space.4. Neverblue.brandorspace.button. - Every hue gets the same 11 steps.
- Use OKLCH. Its lightness is perceptually even, so
blue.600andred.600have the same contrast against white.
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 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. Neverbackgroundin one file andsurfaceorfillin 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, no600, nolightordarkin 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}" }
}
}
}
}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 part resolves down the stack, component to semantic to base:
button.primary.bg.rest→color.bg.action.default.rest→blue.600button.primary.bg.hover→color.bg.action.default.hover→blue.700button.primary.text→color.text.neutral.inverse→whitebutton.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.600won'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 everycolor.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.
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.