How to buildOctober 11, 202610 min read
UX/UI design: from research to a design systemStep 8 of 9

A design system in practice: tokens, components, rules

In short

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.

TutorialLevel: IntermediateDesign SystemsPractice: ~1.5–3 h

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

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
  1. 01What a design system includes and when it pays off
  2. 02Tokens: base and semantic
  3. 03Step 1. Write tokens in the W3C DTCG format
  4. 04Step 2. Turn JSON into CSS custom properties
  5. 05Step 3. A component that only knows tokens
  6. 06Dark mode through semantic tokens
  7. 07Documentation, versions and a minimal starter set

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
Parts of a design system: the first three per NN/g, tokens as Material Design and the W3C DTCG use the term.

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.

  1. 01Basebase.color.brand-600 = #3d4fd6 — palette and scales, no meaning
  2. 02Semanticcolor.accent → base.color.brand-600 — a role; changes per theme
  3. 03Component--button-bg → color.accent — properties of one component
  4. 04UI component.button reads only its own variables
Each tier references only the one below it. A theme change edits the semantic tier, a rebrand edits the base tier.

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 .tokens and .tokens.json
  • A token is an object with a $value property; an object without $value is a group
  • $type can 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
  • $description adds an explanation, $deprecated marks a token as obsolete
tokens/tokens.json
{
  "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] }
    }
  }
}
Base colors, semantic roles, spacing, radii, typography and motion. Checked with a JSON parser and the build step below.

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]
DTCG 2025.10 types used in the file.

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.

build-tokens.mjs
// 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}`);
A teaching converter: it supports only the types in our file. An unknown type is an explicit error, not a silent skip.
terminal
$ node build-tokens.mjs
tokens.css: 40 tokens, dark theme overrides 9
Run it from the root of the tokens folder.
tokens.css
/* 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"] */
Excerpt of the generated file: semantic tokens reference base tokens via var().
  1. 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.

  2. Builds names

    The JSON path is joined with dashes: color → text-muted becomes --color-text-muted.

  3. Keeps references

    "{base.color.brand-600}" becomes var(--base-color-brand-600), so the link between tiers stays visible in code and DevTools.

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

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

components.css
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);
}
A card and a button built on tokens. The 44px minimum height leaves room for a finger tap.
index.html
<!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>
A test page that loads the generated tokens.css and the component styles.

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.

tokens/tokens.dark.json
{
  "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
Contrast of our palette’s semantic pairs, computed by the test script with the WCAG formula. The minimum for normal text under SC 1.4.3 is 4.5: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
A sample component table. “Ready” means the component exists in Figma and in code, with its states described.

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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.