A design system is a set of named decisions (tokens), components and usage rules shared by design and code. A practical start is two tiers of tokens: base tokens hold the palette and scales, semantic tokens name roles such as “accent” or “muted text” and switch in dark mode. Tokens are stored as JSON in the W3C DTCG format and compiled into CSS variables that components read.
After this you will be able to
- Describe color, typography, spacing, radii and motion as DTCG 2025.10 tokens in two tiers: base and semantic
- Build a CSS custom properties file from JSON and confirm that a broken reference stops the build
- Create a component with zero hard-coded values and switch it to dark mode by editing only the semantic tier
- Map token tiers to Figma Variables collections and modes
Know beforehand
Verified with
Design Tokens Format Module 2025.10 (W3C DTCG)Node.js 24.18Microsoft Edge 155 (Playwright 1.64)
Checked against docs: October 11, 2026
Contents
What a design system includes and when it pays off
Nielsen Norman Group defines a design system as a complete set of standards intended to manage design at scale using reusable components and patterns. In practice it is not “a Figma file full of buttons” but an agreement between design and code: every decision — a color, a spacing value, a radius, an animation duration — has a name, and every component has rules for when and how to use it.
Style guide
- What it contains
- Brand, content tone, visual and interaction standards, principles
- Example
- “Buttons are verbs: ‘Book now’, not ‘OK’”
Component library
- What it contains
- Reusable elements with names, descriptions, attributes, states and code
- Example
- Button: primary, secondary, hover, focus
Pattern library
- What it contains
- Groups of elements and screen templates
- Example
- Booking form, service card in a catalog
Tokens
- What it contains
- Named values all three parts are built from
- Example
- color.accent, space.md, radius.lg
When it pays off. According to NN/g, design systems work best when a company foresees years of replicable design work, while for an early prototype a full system is unlikely to pay off soon; creating and maintaining one also requires a dedicated team. Material Design offers a similar filter for tokens: they help little in an existing app with hard-coded values that is unlikely to change in the next year or two.
Tokens: base and semantic
Material Design calls tokens small, reusable design decisions: instead of the value #E8DEF8 you get a self-explanatory name such as md.ref.palette.secondary90. When the designer’s mockup and the developer’s code reference the same name, they are guaranteed to use the same color, even after its value changes.
The minimal working setup has two tiers. Base tokens (reference tokens in Material) hold every allowed value and know nothing about where they are used: base.color.brand-600, base.color.neutral-50. Semantic tokens (system tokens in Material) describe a role in the interface: color.accent, color.text-muted, color.surface. The semantic tier is what switches in dark mode — Material says this is where theming occurs. Material’s third class, component tokens (md.comp…), point to system tokens; in our example that tier lives directly in the component’s CSS.
- 01Basebase.color.brand-600 = #3d4fd6 — palette and scales, no meaning
- 02Semanticcolor.accent → base.color.brand-600 — a role; changes per theme
- 03Component--button-bg → color.accent — properties of one component
- 04UI component.button reads only its own variables
Named by appearance
- --blue, --gray-light, --padding-16
- The brand turned green — and --blue is now green
- Unclear whether --gray-light is fine for text
- Dark mode needs new variables and component edits
Named by role
- --color-accent, --color-text-muted, --space-md
- A rebrand is one reference in the semantic tier
- The role tells you where the token belongs
- Dark mode is another set of values for the same names
Apple’s Human Interface Guidelines follow the same principle: dynamic system colors are defined by purpose rather than appearance, and Apple asks you not to redefine their meaning — for example, not to use the separator color as a text color. Apply the same rule to your own semantic tokens.
Step 1. Write tokens in the W3C DTCG format
On 28 October 2025 the Design Tokens Community Group at W3C published the first stable version of its format, the Design Tokens Format Module 2025.10. It is a Final Community Group Report: considered stable, but not a W3C Standard. Its value is that the same JSON can be read by different tools — Figma plugins, code generators, documentation sites.
- A file is plain JSON; the recommended extensions are
.tokensand.tokens.json - A token is an object with a
$valueproperty; an object without$valueis a group $typecan be set on a group, and every token inside inherits it- A reference to another token uses curly braces:
"{base.color.brand-600}" - Names must not start with
$or contain a dot or curly braces — the dot separates path segments $descriptionadds an explanation,$deprecatedmarks a token as obsolete
{
"base": {
"color": {
"$type": "color",
"neutral-0": { "$value": { "colorSpace": "srgb", "components": [1, 1, 1], "hex": "#ffffff" } },
"neutral-50": { "$value": { "colorSpace": "srgb", "components": [0.961, 0.965, 0.973], "hex": "#f5f6f8" } },
"neutral-200": { "$value": { "colorSpace": "srgb", "components": [0.867, 0.882, 0.906], "hex": "#dde1e7" } },
"neutral-300": { "$value": { "colorSpace": "srgb", "components": [0.769, 0.792, 0.827], "hex": "#c4cad3" } },
"neutral-600": { "$value": { "colorSpace": "srgb", "components": [0.294, 0.333, 0.388], "hex": "#4b5563" } },
"neutral-800": { "$value": { "colorSpace": "srgb", "components": [0.122, 0.149, 0.2], "hex": "#1f2633" } },
"neutral-900": { "$value": { "colorSpace": "srgb", "components": [0.078, 0.102, 0.141], "hex": "#141a24" } },
"neutral-950": { "$value": { "colorSpace": "srgb", "components": [0.047, 0.063, 0.094], "hex": "#0c1018" } },
"brand-200": { "$value": { "colorSpace": "srgb", "components": [0.78, 0.804, 1], "hex": "#c7cdff" } },
"brand-300": { "$value": { "colorSpace": "srgb", "components": [0.647, 0.69, 1], "hex": "#a5b0ff" } },
"brand-600": { "$value": { "colorSpace": "srgb", "components": [0.239, 0.31, 0.839], "hex": "#3d4fd6" } },
"brand-700": { "$value": { "colorSpace": "srgb", "components": [0.184, 0.247, 0.722], "hex": "#2f3fb8" } },
"red-300": { "$value": { "colorSpace": "srgb", "components": [1, 0.608, 0.58], "hex": "#ff9b94" } },
"red-600": { "$value": { "colorSpace": "srgb", "components": [0.769, 0.169, 0.169], "hex": "#c42b2b" } }
}
},
"color": {
"$type": "color",
"$description": "Semantic colors: components use only these",
"bg": { "$value": "{base.color.neutral-0}" },
"surface": { "$value": "{base.color.neutral-50}" },
"border": { "$value": "{base.color.neutral-200}" },
"text": { "$value": "{base.color.neutral-900}" },
"text-muted": { "$value": "{base.color.neutral-600}" },
"accent": { "$value": "{base.color.brand-600}" },
"accent-hover": { "$value": "{base.color.brand-700}" },
"on-accent": { "$value": "{base.color.neutral-0}" },
"danger": { "$value": "{base.color.red-600}" }
},
"space": {
"$type": "dimension",
"xs": { "$value": { "value": 4, "unit": "px" } },
"sm": { "$value": { "value": 8, "unit": "px" } },
"md": { "$value": { "value": 16, "unit": "px" } },
"lg": { "$value": { "value": 24, "unit": "px" } },
"xl": { "$value": { "value": 32, "unit": "px" } }
},
"radius": {
"$type": "dimension",
"sm": { "$value": { "value": 6, "unit": "px" } },
"md": { "$value": { "value": 10, "unit": "px" } },
"lg": { "$value": { "value": 16, "unit": "px" } }
},
"font": {
"family": {
"$type": "fontFamily",
"sans": { "$value": ["Inter", "Segoe UI", "Arial", "sans-serif"] }
},
"size": {
"$type": "dimension",
"body": { "$value": { "value": 1, "unit": "rem" } },
"small": { "$value": { "value": 0.875, "unit": "rem" } },
"title": { "$value": { "value": 1.25, "unit": "rem" } }
},
"weight": {
"$type": "fontWeight",
"regular": { "$value": 400 },
"bold": { "$value": 600 }
},
"line-height": {
"$type": "number",
"body": { "$value": 1.5 }
}
},
"motion": {
"duration": {
"$type": "duration",
"fast": { "$value": { "value": 150, "unit": "ms" } }
},
"easing": {
"$type": "cubicBezier",
"standard": { "$value": [0.2, 0, 0, 1] }
}
}
}color
- $value format
- Object: colorSpace, components (0–1), optional alpha and hex as a fallback
- Example
- { "colorSpace": "srgb", "components": [1, 1, 1], "hex": "#ffffff" }
dimension
- $value format
- Object with value + unit; unit is px or rem only
- Example
- { "value": 16, "unit": "px" }
fontFamily
- $value format
- A string or an array of names in order of preference
- Example
- ["Inter", "Segoe UI", "Arial", "sans-serif"]
fontWeight
- $value format
- A number from 1 to 1000 or an alias: regular, semi-bold, bold…
- Example
- 600
number
- $value format
- A JSON number
- Example
- 1.5 — line height
duration
- $value format
- Object with value + unit; unit is ms or s
- Example
- { "value": 150, "unit": "ms" }
cubicBezier
- $value format
- Four numbers [x1, y1, x2, y2]; x between 0 and 1
- Example
- [0.2, 0, 0, 1]
Step 2. Turn JSON into CSS custom properties
Browsers don’t read token JSON — they need CSS custom properties, also known as CSS variables. As MDN describes, the property name starts with two dashes, the value is read with var(), and declaring it on :root makes it available to the whole page. Names are case-sensitive: --color-text and --Color-text are different variables. Values are inherited by descendants.
The script below runs on Node.js 24 with no dependencies. It walks the groups, inherits $type from the group, turns references into var() and checks that every reference points to an existing token.
// Node.js 24, no dependencies: tokens/*.json (DTCG 2025.10 format) → tokens.css
import { readFileSync, writeFileSync } from "node:fs";
const read = (file) => JSON.parse(readFileSync(file, "utf8"));
const GENERIC_FONTS = new Set(["serif", "sans-serif", "monospace", "system-ui", "cursive"]);
// JSON path → CSS variable name: ["color", "text-muted"] → "--color-text-muted"
const cssName = (path) => `--${path.join("-")}`;
// "{base.color.neutral-0}" → "var(--base-color-neutral-0)"
function alias(value) {
if (typeof value !== "string" || !/^\{[^{}]+\}$/.test(value)) return null;
return `var(${cssName(value.slice(1, -1).split("."))})`;
}
function toCss(type, value) {
const ref = alias(value);
if (ref) return ref;
switch (type) {
case "color": {
const alpha = value.alpha ?? 1;
if (value.hex && alpha === 1) return value.hex;
return `color(${value.colorSpace} ${value.components.join(" ")} / ${alpha})`;
}
case "dimension":
case "duration":
return `${value.value}${value.unit}`;
case "fontFamily":
return [value].flat().map((f) => (GENERIC_FONTS.has(f) ? f : `"${f}"`)).join(", ");
case "fontWeight":
case "number":
return String(value);
case "cubicBezier":
return `cubic-bezier(${value.join(", ")})`;
default:
throw new Error(`Unsupported type "${type}"`);
}
}
// walks the groups; $type is inherited from the nearest group
function collect(node, path = [], type, out = new Map()) {
for (const [key, child] of Object.entries(node)) {
if (key.startsWith("$")) continue;
const childType = child.$type ?? type;
if ("$value" in child) out.set(cssName([...path, key]), toCss(childType, child.$value));
else collect(child, [...path, key], childType, out);
}
return out;
}
const light = collect(read("tokens/tokens.json"));
const dark = collect(read("tokens/tokens.dark.json"));
// every reference must point to an existing token
for (const [name, value] of [...light, ...dark]) {
const target = value.match(/^var\((--[^)]+)\)$/)?.[1];
if (target && !light.has(target)) throw new Error(`${name}: unknown token ${target}`);
}
const block = (vars, pad = " ") => [...vars].map(([n, v]) => `${pad}${n}: ${v};`).join("\n");
const css = `/* Generated by build-tokens.mjs — do not edit by hand */
:root {
${block(light)}
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
${block(dark, " ")}
}
}
:root[data-theme="dark"] {
${block(dark)}
}
`;
writeFileSync("tokens.css", css);
console.log(`tokens.css: ${light.size} tokens, dark theme overrides ${dark.size}`);$ node build-tokens.mjs
tokens.css: 40 tokens, dark theme overrides 9/* Generated by build-tokens.mjs — do not edit by hand */
:root {
--base-color-neutral-0: #ffffff;
/* … base tokens … */
--color-bg: var(--base-color-neutral-0);
--color-surface: var(--base-color-neutral-50);
--color-border: var(--base-color-neutral-200);
--color-text: var(--base-color-neutral-900);
--color-text-muted: var(--base-color-neutral-600);
--color-accent: var(--base-color-brand-600);
--color-accent-hover: var(--base-color-brand-700);
--color-on-accent: var(--base-color-neutral-0);
--color-danger: var(--base-color-red-600);
/* … spacing, radii, font, motion … */
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: var(--base-color-neutral-950);
--color-surface: var(--base-color-neutral-900);
/* … the remaining semantic colors … */
}
}
/* … and the same set in :root[data-theme="dark"] */Reads two files
tokens.json holds every token and the light values of the roles; tokens.dark.json holds only the roles that change in dark mode.
Builds names
The JSON path is joined with dashes: color → text-muted becomes --color-text-muted.
Keeps references
"{base.color.brand-600}" becomes var(--base-color-brand-600), so the link between tiers stays visible in code and DevTools.
Checks integrity
We put a nonexistent neutral-1000 into the dark theme — the build stopped with an error instead of shipping CSS with an empty variable.
Writes three blocks
:root with light values, a media query for the system dark theme, and :root[data-theme="dark"] for a manual choice.
Step 3. A component that only knows tokens
The component styles contain no hex values and no magic numbers: colors, spacing, radii, font and transition duration all come from tokens. The button has its own small tier — --button-bg, --button-fg, --button-border. The secondary variant and hover change only these three variables instead of redefining properties, so a new variant takes three lines.
body {
margin: 0;
padding: var(--space-xl);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family-sans);
font-size: var(--font-size-body);
line-height: var(--font-line-height-body);
}
.card {
max-width: 360px;
padding: var(--space-lg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.card__title {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-title);
font-weight: var(--font-weight-bold);
}
.card__text {
margin: 0 0 var(--space-md);
color: var(--color-text-muted);
}
.card__actions {
display: flex;
gap: var(--space-sm);
}
/* component tokens: the button reads only its own variables */
.button {
--button-bg: var(--color-accent);
--button-fg: var(--color-on-accent);
--button-border: var(--color-accent);
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 var(--space-md);
border: 1px solid var(--button-border);
border-radius: var(--radius-md);
background: var(--button-bg);
color: var(--button-fg);
font: inherit;
font-weight: var(--font-weight-bold);
cursor: pointer;
transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
}
.button:hover {
--button-bg: var(--color-accent-hover);
--button-border: var(--color-accent-hover);
}
.button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.button--secondary,
.button--secondary:hover {
--button-bg: transparent;
--button-fg: var(--color-accent);
--button-border: var(--color-border);
}
.button--secondary:hover {
--button-bg: var(--color-bg);
}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tokens in action</title>
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="components.css" />
</head>
<body>
<article class="card">
<h2 class="card__title">Book a consultation</h2>
<p class="card__text">Every color, spacing value and radius on this card comes from tokens.</p>
<div class="card__actions">
<button class="button" type="button">Book now</button>
<button class="button button--secondary" type="button">Learn more</button>
</div>
</article>
</body>
</html>How to check the result. Run node build-tokens.mjs, open index.html and look at the Computed tab for the primary button in DevTools: background-color should be rgb(61, 79, 214) — the value of base.color.brand-600 passed through color.accent and --button-bg. We ran this page in headless Microsoft Edge via Playwright: light and dark system themes, a manual data-theme in both directions, hover and reference checks — all 9 automated checks passed.
Dark mode through semantic tokens
Dark mode is a second set of values for the same semantic tokens. The base palette and the components stay the same: the file below overrides nine roles and nothing else.
{
"color": {
"$type": "color",
"bg": { "$value": "{base.color.neutral-950}" },
"surface": { "$value": "{base.color.neutral-900}" },
"border": { "$value": "{base.color.neutral-800}" },
"text": { "$value": "{base.color.neutral-50}" },
"text-muted": { "$value": "{base.color.neutral-300}" },
"accent": { "$value": "{base.color.brand-300}" },
"accent-hover": { "$value": "{base.color.brand-200}" },
"on-accent": { "$value": "{base.color.neutral-950}" },
"danger": { "$value": "{base.color.red-300}" }
}
}In tokens.css the dark values appear twice. The prefers-color-scheme: dark media query with the :root:not([data-theme="light"]) selector follows the system setting unless the person has explicitly chosen the light theme. The :root[data-theme="dark"] block turns dark mode on by choice — a toggle handler only needs document.documentElement.dataset.theme = "dark".
text / bg
- Light theme
- 17.46:1
- Dark theme
- 17.61:1
text-muted / surface
- Light theme
- 6.99:1
- Dark theme
- 10.59:1
on-accent / accent
- Light theme
- 6.39:1
- Dark theme
- 9.30:1
accent / surface
- Light theme
- 5.91:1
- Dark theme
- 8.52:1
danger / bg
- Light theme
- 5.63:1
- Dark theme
- 9.40:1
In Figma the same structure is built from variables. According to Figma Help, a collection is a set of variables and modes; a mode stores one value per variable for its context — light and dark themes, languages, device sizes. A variable can reference another variable of the same type, which is an alias. The types are color, number, string and boolean, plus timing and easing for Figma Motion. A handy mapping: a “Base” collection without modes mirrors the base group, and a “Semantic” collection with Light and Dark modes mirrors tokens.json and tokens.dark.json.
The DTCG has a separate document for themes, the Resolver Module 2025.10. It describes token sets and modifiers with contexts (such as light and dark) in a .resolver.json file. Our two files plus a script are a simplified version of the same idea; if your tool supports the resolver, describe themes there.
Documentation, versions and a minimal starter set
A system nobody maintains drifts away from the product within a few releases. NN/g stresses that design systems require continuous maintenance and names a minimal team: an interaction designer, a visual designer and a developer. Documentation doesn’t have to be a separate portal: at the start, a component table with statuses and token references is enough.
Button
- Status
- Ready
- Tokens
- --button-*, color.accent, radius.md, motion.*
- States and rules
- Primary and secondary; hover, focus. One primary button per screen
Card
- Status
- Ready
- Tokens
- color.surface, color.border, radius.lg, space.lg
- States and rules
- No shadow in dark mode: surface color provides depth
Text field
- Status
- In progress
- Tokens
- color.border, color.danger, radius.md
- States and rules
- Focus, error with text, disabled
Badge
- Status
- Draft
- Tokens
- color.accent, font.size.small
- States and rules
- Needs a pill radius token
Modal
- Status
- Not started
- Tokens
- —
- States and rules
- Define focus and keyboard closing first
Token names are a public interface for code, just like a library’s functions. Semantic Versioning says to bump the major version for incompatible API changes, the minor version for backward-compatible additions and the patch version for fixes. Our advice is to apply this to tokens literally: renaming or removing a token is a major version, a new token is a minor one, a corrected value is a patch. Before removing a token, mark it $deprecated with a hint about its replacement.
- Base palette: neutrals, brand, error and success colors
- 8–12 semantic colors: background, surface, border, text, muted text, accent, text on accent, error
- A spacing scale on a 4/8 step — see the visual hierarchy lesson
- 3–5 font sizes and 2 weights — from the typography and color lesson
- 2–3 radii, 2–3 durations and one or two easing curves
- Button, text field and card with all their states
- A component table with statuses and a versioning rule
How this was verified6 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.