Building a Design System

Chapter 11 — Building a Design System: Token-Based Theming

A design system is a single source of truth for visual decisions — colours, spacing, typography, radius, shadow — shared between designers and engineers. Design tokens are the atomic unit of that system: named values that carry meaning rather than raw numbers. --color-accent-500 is a token. oklch(0.60 0.18 248) is just a colour. Tokens are what let you swap a theme, add dark mode, or rebrand a product by changing one file rather than a thousand rules.

1. The Three Token Tiers

Tier 1 — Primitive Tokens (the palette)
Raw values. No meaning. Never used directly in components. Changed only when the palette itself is updated. Designers define these first.
--blue-500 --blue-400 --green-500 --red-500 --amber-400 --purple-500 --grey-950 --space-4 --space-8 --radius-md --font-size-sm
Tier 2 — Semantic Tokens (the contract)
References to primitives, but named for their purpose. This is the layer that changes between themes and colour schemes. Components use these — never primitives directly.
--color-bg --color-surface --color-text --color-accent --color-success --color-danger --color-border --space-component-gap --radius-card --shadow-card
Tier 3 — Component Tokens (the detail)
References to semantic tokens, scoped to a specific component. Allow fine-grained overrides without touching the semantic layer. Optional — not all systems need this tier.
--btn-bg --btn-text --btn-radius --card-bg --card-border --input-bg --input-border-focus --nav-height
The cardinal rule: components must only consume Tier 2 (semantic) or Tier 3 (component) tokens. Never reference a primitive token (--blue-500) directly in a component. If you do, swapping the theme breaks the component — a blue background stays blue even when the theme is "green".

2. Token Naming Conventions

--color-accent-interactive-hover
color
accent
interactive
hover
Category
Role / family
Context
State / variant
/* ── Naming segments (use what you need, left to right) ──────── */ /* {category}-{role}-{variant}-{scale}-{state} */ /* Category: what kind of token */ /* color / space / size / radius / shadow / font / duration / easing */ /* Role: what it's for (semantic) */ /* bg / surface / text / border / accent / success / danger / warning / info */ /* Variant: sub-role or emphasis */ /* strong / subtle / muted / inverse / on-accent */ /* Scale: numeric step within a category */ /* 50 / 100 / 200 … 950 (primitives) or sm / md / lg (semantics) */ /* State: interaction or condition */ /* default / hover / active / focus / disabled / placeholder */ /* ── Practical name examples ─────────────────────────────────── */ /* Primitives */ --color-blue-500 --color-grey-100 --space-4 /* 4px (or 0.25rem on a 4pt grid) */ --radius-md --font-size-sm /* Semantic */ --color-bg --color-bg-subtle --color-surface --color-text --color-text-subtle --color-text-on-accent --color-accent --color-accent-hover --color-success --color-danger --color-border --color-border-strong --space-component-gap /* space between component children */ --space-section-gap /* space between page sections */ --radius-component --shadow-card --duration-short /* 150ms */ --duration-medium /* 250ms */ --easing-default /* Component */ --btn-bg --btn-bg-hover --btn-text --btn-radius --btn-padding-inline --card-bg --card-shadow

3. Full Token Architecture in CSS

/* ════════════════════════════════════════════════════════════ */ /* tokens/primitives.css */ /* The palette — raw values, no semantic meaning */ /* ════════════════════════════════════════════════════════════ */ :root { /* Colour primitives — oklch scale, H=248 (blue) */ --blue-950: oklch(0.18 0.18 248); --blue-900: oklch(0.26 0.18 248); --blue-700: oklch(0.42 0.18 248); --blue-500: oklch(0.60 0.18 248); --blue-400: oklch(0.70 0.18 248); --blue-200: oklch(0.85 0.18 248); --blue-50: oklch(0.96 0.18 248); /* Neutral scale — very low chroma, same hue for tonal harmony */ --grey-950: oklch(0.12 0.01 248); --grey-900: oklch(0.18 0.01 248); --grey-700: oklch(0.35 0.01 248); --grey-500: oklch(0.55 0.01 248); --grey-300: oklch(0.75 0.01 248); --grey-100: oklch(0.93 0.01 248); --grey-50: oklch(0.97 0.01 248); /* Status colours */ --green-500: oklch(0.60 0.18 145); --green-200: oklch(0.85 0.18 145); --red-500: oklch(0.60 0.18 25); --red-200: oklch(0.85 0.18 25); --amber-500: oklch(0.70 0.18 65); --amber-200: oklch(0.88 0.18 65); /* Spacing — 4px base grid */ --space-1: 0.25rem; --space-2: 0.5rem; --space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem; --space-12:3rem; --space-16:4rem; --space-24:6rem; /* Radius */ --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; --radius-xl: 16px; --radius-full: 9999px; /* Typography */ --font-size-xs: 0.75rem; --font-size-sm: 0.875rem; --font-size-md: 1rem; --font-size-lg: 1.125rem; --font-size-xl: 1.25rem; --font-size-2xl:1.5rem; --font-weight-normal: 400; --font-weight-bold: 700; /* Motion */ --duration-short: 150ms; --duration-medium: 250ms; --duration-long: 400ms; --easing-default: cubic-bezier(0.16, 1, 0.3, 1); --easing-in: cubic-bezier(0.4, 0, 1, 1); }
/* ════════════════════════════════════════════════════════════ */ /* tokens/semantic.css */ /* The contract — maps primitives to purpose */ /* This file is swapped (or overridden) per theme */ /* ════════════════════════════════════════════════════════════ */ :root { color-scheme: light dark; /* Background layers */ --color-bg: light-dark(var(--grey-50), var(--grey-950)); --color-bg-subtle: light-dark(var(--grey-100), var(--grey-900)); --color-surface: light-dark(oklch(1 0 0), var(--grey-900)); --color-surface-2: light-dark(var(--grey-50), var(--grey-950)); /* Text */ --color-text: light-dark(var(--grey-950), var(--grey-100)); --color-text-subtle: light-dark(var(--grey-500), var(--grey-500)); --color-text-on-accent: oklch(1 0 0); /* always white on coloured bg */ /* Accent (brand colour) */ --color-accent: light-dark(var(--blue-500), var(--blue-400)); --color-accent-hover: oklch(from var(--color-accent) calc(l + 0.07) c h); --color-accent-subtle:color-mix(in oklch, var(--color-accent) 12%, var(--color-bg)); /* Status */ --color-success: light-dark(var(--green-500), oklch(0.72 0.18 145)); --color-danger: light-dark(var(--red-500), oklch(0.72 0.18 25)); --color-warning: light-dark(var(--amber-500), oklch(0.82 0.18 65)); /* Border */ --color-border: light-dark(var(--grey-300), var(--grey-700)); --color-border-strong:light-dark(var(--grey-700), var(--grey-300)); /* Shadow */ --shadow-sm: 0 1px 3px oklch(0 0 0 / 0.08); --shadow-card: 0 4px 16px oklch(0 0 0 / 0.12); --shadow-lg: 0 16px 48px oklch(0 0 0 / 0.18); /* Semantic spacing */ --space-component-gap: var(--space-4); --space-section-gap: var(--space-12); --radius-component: var(--radius-md); --radius-input: var(--radius-sm); }

4. Multi-Brand and Multi-Theme Support

A properly layered token system lets you ship multiple brands or colour themes by overriding only the semantic layer. The components and primitive layer are identical across all themes — only the mapping changes.

Same component markup, three brand themes — only the token override layer changes:
Theme: Blue (default)
Primary action
Card title
Body text uses --color-text-subtle
Theme: Green
Primary action
Card title
Body text uses --color-text-subtle
Theme: Purple
Primary action
Card title
Body text uses --color-text-subtle
/* ── Multi-brand theme via data attribute ────────────────────── */ /* <html data-theme="green"> — one attribute switches the whole UI */ :root, [data-theme="blue"] { --brand-hue: 248; --brand-chroma: 0.18; } [data-theme="green"] { --brand-hue: 145; --brand-chroma: 0.20; } [data-theme="purple"] { --brand-hue: 300; --brand-chroma: 0.22; } /* Semantic tokens derive from the brand variables */ :root { --color-accent: light-dark( oklch(0.55 var(--brand-chroma) var(--brand-hue)), oklch(0.70 var(--brand-chroma) var(--brand-hue)) ); --color-accent-hover: light-dark( oklch(0.62 var(--brand-chroma) var(--brand-hue)), oklch(0.77 var(--brand-chroma) var(--brand-hue)) ); --color-accent-subtle: oklch(from var(--color-accent) light-dark(0.95, 0.20) calc(c * 0.3) h); } /* JavaScript to switch theme */ document.documentElement.dataset.theme = 'green'; /* Everything updates instantly — no class replacement, no JS style injection */

5. Component Tokens and Overrides

/* ── Component tokens defined at :root, consuming semantic tokens ─ */ :root { /* Button */ --btn-bg: var(--color-accent); --btn-bg-hover: var(--color-accent-hover); --btn-text: var(--color-text-on-accent); --btn-radius: var(--radius-component); --btn-padding-block:var(--space-2); --btn-padding-inline:var(--space-4); --btn-font-size: var(--font-size-sm); /* Card */ --card-bg: var(--color-surface); --card-border: 1px solid var(--color-border); --card-radius: var(--radius-md); --card-padding: var(--space-6); --card-shadow: var(--shadow-card); /* Input */ --input-bg: var(--color-surface); --input-border: 1px solid var(--color-border); --input-border-focus:2px solid var(--color-accent); --input-radius: var(--radius-input); --input-padding: var(--space-2) var(--space-4); } /* ── Component CSS consumes only component tokens ─────────────── */ .btn { background: var(--btn-bg); color: var(--btn-text); border-radius: var(--btn-radius); padding: var(--btn-padding-block) var(--btn-padding-inline); font-size: var(--btn-font-size); transition: background var(--duration-short) var(--easing-default); } .btn:hover { background: var(--btn-bg-hover); } /* ── Component-level override in a specific context ──────────── */ /* Override at the component token level — the component CSS is untouched */ .marketing-hero { --btn-radius: var(--radius-full); /* pill shape in hero only */ --btn-padding-inline: var(--space-8); /* wider padding in hero only */ }

6. Spacing and Type Scales

4px base grid — spacing tokens on a geometric scale:
--space-1
4px
--space-2
8px
--space-3
12px
--space-4
16px
--space-6
24px
--space-8
32px
--space-12
48px
--space-16
64px
--space-24
96px
/* ── Fluid type scale with clamp() ───────────────────────────── */ /* Type scales using clamp() + rem units adapt to viewport and user font-size */ :root { /* Fluid scale: clamp(min, preferred, max) */ /* preferred: vw unit creates proportional scaling */ --font-size-xs: clamp(0.70rem, 0.65rem + 0.2vw, 0.75rem); --font-size-sm: clamp(0.85rem, 0.80rem + 0.2vw, 0.90rem); --font-size-md: clamp(1.00rem, 0.95rem + 0.3vw, 1.10rem); --font-size-lg: clamp(1.15rem, 1.05rem + 0.5vw, 1.35rem); --font-size-xl: clamp(1.30rem, 1.10rem + 0.9vw, 1.70rem); --font-size-2xl: clamp(1.60rem, 1.20rem + 1.8vw, 2.20rem); --font-size-3xl: clamp(2.00rem, 1.40rem + 2.8vw, 3.00rem); }

7. Style Dictionary: Tokens as a Single Source of Truth

Style Dictionary (by Amazon) takes a JSON or JavaScript token definition and transforms it into platform-specific outputs: CSS custom properties, iOS Swift constants, Android XML, Tailwind config, JavaScript objects, and more. It makes tokens the authoritative single source that feeds every platform.

tokens/*.json
Figma / design tool
or hand-authored
Style Dictionary
Transform + format
pipeline
outputs/
tokens.css
tokens.js
tokens.swift
tailwind.config.js
/* ── tokens/color.json — the source token file ───────────────── */ { "color": { "blue": { "500": { "$value": "oklch(0.60 0.18 248)", "$type": "color" }, "400": { "$value": "oklch(0.70 0.18 248)", "$type": "color" } }, "accent": { "default": { "$value": "{color.blue.500}", /* reference to another token */ "$type": "color" } } }, "space": { "4": { "$value": "16px", "$type": "dimension" } } } /* ── style-dictionary.config.js ──────────────────────────────── */ export default { source: ['tokens/**/*.json'], platforms: { css: { transformGroup: 'css', files: [{ destination: 'dist/tokens.css', format: 'css/variables', options: { outputReferences: true }, }], }, js: { transformGroup: 'js', files: [{ destination: 'dist/tokens.js', format: 'javascript/esm', }], }, }, }; /* ── Generated dist/tokens.css ───────────────────────────────── */ :root { --color-blue-500: oklch(0.60 0.18 248); --color-blue-400: oklch(0.70 0.18 248); --color-accent-default: var(--color-blue-500); /* reference preserved */ --space-4: 16px; }

8. Versioning, Documentation, and Governance

/* ── Semantic versioning for design tokens ───────────────────── */ /* MAJOR: removing or renaming a token (breaking — consumers must update) */ /* MINOR: adding a new token (safe — consumers can opt in) */ /* PATCH: changing a token's value within acceptable range (e.g. L+0.02) */ /* ── Deprecation pattern ─────────────────────────────────────── */ /* Never remove a token without a deprecation period */ :root { /* @deprecated — use --color-accent instead. Will be removed in v5. */ --primary-blue: var(--color-accent); /* alias pointing to new token */ } /* ── Token documentation in JSON (W3C Design Token format) ──── */ { "color-accent": { "$value": "{color.blue.500}", "$type": "color", "$description": "Primary interactive colour. Use on buttons, links, and focus rings.", "$extensions": { "com.myco.tokens": { "status": "stable", "since": "2.0.0", "usedBy": ["Button", "Link", "Input", "Badge"] } } } } /* ── Integrating tokens into Tailwind ─────────────────────────── */ /* Map CSS custom properties into Tailwind's theme */ export default { theme: { colors: { bg: 'var(--color-bg)', surface: 'var(--color-surface)', text: 'var(--color-text)', accent: 'var(--color-accent)', border: 'var(--color-border)', success: 'var(--color-success)', danger: 'var(--color-danger)', }, spacing: { 1: 'var(--space-1)', 2: 'var(--space-2)', 4: 'var(--space-4)', 8: 'var(--space-8)', }, borderRadius: { component: 'var(--radius-component)', full: 'var(--radius-full)', }, }, }; /* Now Tailwind classes like bg-accent, text-text, p-4, rounded-component */ /* all resolve to the CSS custom properties — theme switch works in Tailwind too */

Chapter Summary

ConceptKey point
Three tiersPrimitive tokens (raw values, the palette), semantic tokens (purpose-named references to primitives — the contract), component tokens (scoped to a component, references to semantics). Components consume semantic or component tokens only — never primitives directly.
Naming conventioncategory-role-variant-scale-state. Read left to right from general to specific. Semantic tokens drop the scale and describe purpose: --color-accent, --color-border-strong, --space-component-gap. Consistent naming enables IDE autocomplete and documentation generation.
light-dark() themingDefine all semantic colour tokens using light-dark() — one declaration covers both colour schemes. Declare color-scheme: light dark on :root. Use color-scheme: dark on a subtree to force dark values in a specific section (e.g. a promotional dark banner inside a light page).
Multi-brand themingExtract the brand's hue and chroma into --brand-hue and --brand-chroma primitives. Derive all accent-family semantic tokens from those variables. Switch theme by setting data-theme="green" on the html element — no JavaScript style injection, no class toggling across hundreds of elements.
Component tokensOptional third tier. Define --btn-bg, --card-padding, --input-border-focus at :root from semantic tokens. Components consume component tokens. Context-specific overrides change component tokens locally without touching component CSS: .marketing-hero { --btn-radius: var(--radius-full); }
Spacing scale4px (0.25rem) base grid. Multiples: 1, 2, 3, 4, 6, 8, 12, 16, 24. Map to semantic tokens: --space-component-gap: var(--space-4). Use clamp() for fluid type scales that adapt to both viewport width and user font-size preferences.
Style DictionaryTakes JSON token files (using the W3C Design Token Community Group format) and transforms them into CSS custom properties, JavaScript exports, iOS/Android constants, and Tailwind config. Token references ({color.blue.500}) are preserved as var() in CSS output. Enables tokens as a true single source of truth across platforms.
VersioningTreat tokens like an API. Major version: rename or remove (breaking). Minor version: add new tokens (safe). Patch: adjust a value within acceptable bounds. Deprecate with alias tokens before removal. Document $description and $status in the JSON source.
Exercises
  1. Build the full three-tier token stack: Create three CSS files: primitives.css, semantic.css, and components.css. In primitives, define a complete blue scale (50–950 in oklch), a neutral scale, and spacing tokens (1–24 on a 4px grid). In semantic, map those to purpose-named tokens using light-dark() for all colour values. In components, define --btn-* and --card-* tokens referencing semantic tokens. Write a Button and Card component using only their respective component tokens. Change the neutral hue in primitives from 248 to 30 (warm) and observe that all three tiers update without touching component CSS.
  2. Multi-brand theme switcher: Implement three brand themes (blue, green, purple) using data-theme on <html>. Each theme sets --brand-hue and --brand-chroma. Derive all accent tokens from those variables. Add a JavaScript theme switcher that reads the current theme from localStorage on page load and applies it. Verify that a page reload preserves the selected theme and that every coloured element updates instantly on switch.
  3. Fluid type scale: Define a seven-step fluid type scale using clamp() for sizes xs through 3xl. Use utopia.fyi or the formula clamp(min, min + (max-min) * (100vw - 320px) / (1280-320), max) to calculate exact values for a 320px–1280px fluid range. Apply the scale to a prose stylesheet via semantic typography tokens (--font-size-body, --font-size-heading-lg, etc.). Verify by resizing the browser: text should scale smoothly between the min and max values with no media query breakpoints.
  4. Style Dictionary pipeline: Set up a minimal Style Dictionary project. Write a tokens/color.json using the W3C Design Token Community Group $value/$type format. Include at least five colour tokens and three spacing tokens with a token reference ({color.blue.500}). Configure Style Dictionary to output a dist/tokens.css file with CSS custom properties and a dist/tokens.js ES module. Run the build and confirm references in CSS output use var() syntax. Then add a second JSON file for dark-mode overrides and wire it as a separate platform output.
  5. Token audit — find primitive leaks: Take an existing stylesheet (your own or an open-source UI library). Search for any raw colour values, px values, or hardcoded numbers used directly in component rules rather than referencing a token. List every "primitive leak" — a value that should be a token but isn't. Propose a token name for each one following the category-role-variant convention. Convert the three worst offenders to proper token references and verify the visual output is identical.
Next: Chapter 12 — Project: Component Library with Modern CSS. The capstone chapter. Build a production-ready component library from scratch using the techniques from all eleven chapters: a full three-tier token system, @layer architecture, container queries on every component, logical properties throughout, oklch colour theming, scroll-driven animations, will-change and contain for performance, CSS Modules for scoping, and light-dark() for zero-JavaScript dark mode.