Token pipelines usually fail at the hand-off. The designer updates a variable in Figma, someone forgets to update the code, and three months later the product has two shades of the same blue. The fix is boring and reliable: one file, generated everywhere.
Start with one source file#
Figma’s variables export as JSON. We keep that file in the repository as tokens.json and treat it as the only place a colour, a space or a radius is defined. Everything else — CSS, the documentation site, the MCP server’s DESIGN.md — is generated from it.
{
"color": {
"violet-500": { "value": "#7C5CEF" },
"action-primary": { "value": "{color.violet-500}" }
},
"radius": { "card": { "value": "12px" } }
}Generate the CSS at build time#
A small script walks the JSON and writes custom properties. Because it runs in the build, a token a component asks for but the file does not have fails the build instead of rendering as nothing.
:root {
--color-violet-500: #7C5CEF;
--color-action-primary: var(--color-violet-500);
--radius-card: 12px;
}Two layers, one rule#
- Primitive tokens hold raw values:
violet-500is#7C5CEFand means nothing else. - Semantic tokens hold intent:
action-primarypoints at a primitive. - Components read semantic tokens only — that one rule makes a rebrand a one-line change.
Keep it honest in CI#
A lint rule that fails on raw hex values outside the token file takes an afternoon to write and removes a whole category of drift. We run it on every pull request; it is the cheapest check we have.
| Check | What it catches | Cost |
|---|---|---|
| Raw hex lint | Colours typed by hand | An afternoon |
| Missing token fails build | A component asking for a token that does not exist | Free with generation |
| Snapshot at 3 breakpoints | A token change that breaks a layout | Minutes per run |
None of this is clever. That is the point: a pipeline nobody has to remember to run is the only one that survives a busy quarter.

