Design Systems and Tokens

Turning one-off decisions into named, versioned, reusable values and components.
Author

Benedict Thekkel

A design system is the set of decisions a product has already made, written down in a form both designers and code can use. Its purpose is not consistency for its own sake. It is to stop a team re-deciding the same things, differently, every sprint.

Three terms get used interchangeably and are not the same thing:

Thing What it is Lives as
Style guide Documentation of visual rules. Read by humans A page, a PDF
Component library Implemented, reusable components An npm package, a Figma library
Design system Tokens, components, patterns, usage guidance, plus the process that maintains them All of the above, versioned, with owners

The part that most often gets skipped is the last clause. A component library with no owner, no versioning and no decision process is not a design system, it is a folder. It will drift, teams will fork it, and within a year there will be three buttons again.

This notebook covers tokens (the values), components (the assembled units), and governance (what keeps them true). The raw material, meaning the scales and the colour theory, is in Visual Design Foundations.


1. Tokens: the three tiers

A design token is a named design decision. The value of naming it is not brevity, it is indirection: you can change what a name points at without hunting through code.

The architecture that survives contact with reality has three tiers.

flowchart LR
    subgraph T1["Tier 1: primitives"]
      A["blue-600 = oklch(54% .17 250)"]
      B["space-4 = 1rem"]
      C["grey-900 = #0f172a"]
    end
    subgraph T2["Tier 2: semantic"]
      D["color-action"]
      E["color-text-primary"]
      F["space-field-gap"]
    end
    subgraph T3["Tier 3: component"]
      G["button-bg"]
      H["input-border"]
      I["card-padding"]
    end
    A --> D --> G
    C --> E
    B --> F --> I
    D --> H

Tier 1, primitives. The raw palette and scales. Names describe the value: blue-600, space-4, text-lg, radius-md. No meaning attached, so they are safe to define once and rarely change.

Tier 2, semantic. Names describe the job: color-action, color-text-secondary, color-border-subtle, color-danger, space-section-gap. This tier is the one that earns the whole system, because it is where theming happens and where intent is recorded. color-action can be rebranded from blue to green by editing one line.

Tier 3, component. Optional, and only worth adding for components that need to vary independently: button-primary-bg, input-border-rest. Skip this tier until a real need appears, or you will maintain hundreds of aliases that all point straight through.

The rule that makes tokens work: application code references tier 2, never tier 1. The moment a component uses blue-600 directly, it has opted out of theming and nobody will notice until dark mode ships.

/* Tier 1: primitives */
:root {
  --blue-500: oklch(62% .16 250);
  --blue-600: oklch(54% .17 250);
  --grey-50:  oklch(98% .005 250);
  --grey-500: oklch(60% .02 250);
  --grey-900: oklch(22% .02 250);
  --red-600:  oklch(55% .19 25);
}

/* Tier 2: semantic. The only tier components are allowed to use. */
:root {
  --color-surface:        var(--grey-50);
  --color-text-primary:   var(--grey-900);
  --color-text-secondary: var(--grey-500);
  --color-action:         var(--blue-600);
  --color-action-hover:   var(--blue-500);
  --color-danger:         var(--red-600);
  --color-border-subtle:  oklch(90% .01 250);
  --focus-ring:           var(--blue-500);
}

/* Component: consumes tier 2 only */
.btn-primary {
  background: var(--color-action);
  color: var(--color-on-action);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-md);
}
.btn-primary:hover { background: var(--color-action-hover); }

2. Naming

Naming is most of the difficulty, and a bad scheme is expensive to correct once a hundred components consume it.

Use a consistent ordered pattern. A common one is category-concept-property-variant-state, applied loosely but predictably:

color-text-primary
color-text-primary-inverse
color-bg-surface-raised
color-border-input-error
space-inline-sm
radius-control
shadow-overlay

Name by role, never by appearance. This is the single most important naming rule and the one most often broken:

Do not Do Why
color-blue-button color-action The brand will change colour; the role will not
color-grey-light color-border-subtle “Light” is wrong in dark mode
space-16 used semantically space-section-gap Encodes the decision, not the number
text-small-grey color-text-secondary Two properties fused into one name

The dark mode test settles any naming argument: if a token name becomes false when the theme flips, the name describes appearance rather than role. color-grey-light is a lie in a dark theme. color-border-subtle stays true.

Other conventions worth fixing early. Pick one word per concept and never its synonyms (bg or background, not both). Use -inverse for text on a coloured ground, and on- prefixes if you prefer the Material convention (color-on-action). Keep abbreviations to a tiny agreed list. And write the name in one casing in the source of truth, letting the build emit whatever each platform wants.


3. One source of truth, many outputs

Tokens are most useful when the same values reach CSS, iOS, Android, Figma and documentation without anyone retyping them. The usual arrangement is a platform-neutral file, conventionally JSON, and a build step.

{
  "color": {
    "action": {
      "$value": "{color.blue.600}",
      "$type": "color",
      "$description": "Primary action: filled buttons, active nav, links"
    }
  },
  "space": {
    "field-gap": { "$value": "{space.4}", "$type": "dimension" }
  }
}

That shape is the W3C Design Tokens community format ($value, $type, $description, with {} references), which is worth adopting because tooling is converging on it. Style Dictionary and Tokens Studio both read and write it.

flowchart LR
    S["tokens.json<br/>(W3C format)"] --> B["Style Dictionary build"]
    B --> CSS["CSS custom properties"]
    B --> TS["TypeScript constants"]
    B --> IOS["Swift / iOS"]
    B --> AND["Android XML"]
    B --> DOC["Docs site tables"]
    F["Figma variables"] <--> S

Keep the pipeline one-directional if you can. Two-way sync between Figma and a repository sounds appealing and produces merge conflicts nobody knows how to resolve. Pick which side is authoritative, and it is usually the repository, because that is the one with review, history and CI.

The value of the build step is enforcement. A token that exists in Figma and not in CSS is where drift starts. Generating both from one file makes drift a build failure rather than a slow divergence.


4. Theming

If tier 2 is doing its job, a theme is a table of overrides and nothing else.

/* Light is the default, defined on bare :root so it is never conditional */
:root {
  --color-surface: var(--grey-50);
  --color-surface-raised: #fff;
  --color-text-primary: var(--grey-900);
  --color-border-subtle: oklch(90% .01 250);
  --color-action: var(--blue-600);
  --shadow-overlay: 0 8px 24px rgb(0 0 0 / .12);
}

/* System preference, but never override an explicit light choice */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-surface: oklch(18% .015 250);
    --color-surface-raised: oklch(23% .015 250);   /* depth by lightness, not shadow */
    --color-text-primary: oklch(95% .01 250);
    --color-border-subtle: oklch(32% .015 250);
    --color-action: oklch(70% .13 250);            /* desaturated: saturated blues vibrate on dark */
    --shadow-overlay: 0 8px 24px rgb(0 0 0 / .5);
  }
}

/* Explicit choice wins in both directions */
:root[data-theme="dark"] { /* same dark block */ }

Three states, not two. Light, dark, and “follow the system”, which is the default. A toggle that only flips a class cannot express the third, so store the preference as light, dark or system and only stamp an attribute for the first two.

What changes in dark mode beyond swapping greys:

  • Desaturate accents. A colour that reads correctly on white glows unpleasantly on near-black.
  • Avoid pure black and pure white. #000 with #fff text causes halation, where the text appears to bleed. Use a very dark grey with a slightly dimmed white.
  • Elevation inverts. Raised surfaces get lighter, because a dark shadow on a dark ground is invisible.
  • Images and illustrations need attention. A screenshot with a white background becomes a glowing rectangle. Consider a filter for line art, or ship two assets.
  • Re-check contrast. Passing in light says nothing about dark; the ratios are independent.

Never define a colour only inside a media query. If the light value lives in @media (prefers-color-scheme: light) and a user has no preference set, the token is undefined. Bare :root carries the default; the media query only overrides.


5. Components

Tokens make values consistent. Components make behaviour consistent, which is the harder half.

Design the API, not just the appearance. A component’s props are its contract and are as much a design decision as its padding.

// Constrained variants beat open styling props.
type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  disabled?: boolean;
  iconStart?: ReactNode;
  children: ReactNode;          // the label; never optional for an icon-only case
};

Rules that keep a component library usable.

  • Enumerate variants; do not accept arbitrary styles. A color prop taking any string means the system no longer knows what exists. Adding a variant should be a deliberate, reviewed act.
  • Do not leak layout. A component should not set its own outer margin, because the parent owns spacing. This one rule prevents a large share of “why is there a gap here” bugs.
  • Handle every state in the component, including loading, disabled, error and focus, so consumers cannot forget them. A loading prop that also disables the button and preserves its width is a small thing that prevents layout jump everywhere it is used.
  • Accessibility belongs inside. Focus ring, aria-*, roles, keyboard handling and the accessible name requirement live in the component, so that correctness is the default rather than a per-use effort. This is the strongest practical argument for a component library at all.
  • Provide an escape hatch, and watch it. A className passthrough is pragmatic, but heavy use of it is the signal that the system is missing something real.

Anatomy documentation matters. For each component record its parts, its variants, its states, when to use it, and explicitly when not to with a pointer to the right alternative. The “when not to” section is what stops a tab bar being used for navigation between pages.

Consider headless primitives for complex controls. Combobox, dialog, menu, date picker and tooltip are each deceptively hard to get right for keyboard and screen reader use. Libraries such as Radix, React Aria or Headless UI supply behaviour and accessibility while leaving styling to your tokens. Building these from scratch is a recognised way to ship an inaccessible product.


6. Governance and versioning

This is the part that decides whether the system is alive in two years.

Name owners. A system with no owner becomes a system nobody trusts. That does not require a dedicated team: a rotating maintainer plus a review requirement works for a small product.

Decide how a change gets in. A lightweight, written path: propose, discuss, accept or reject with a recorded reason. The rejections matter as much as the additions, because a system that accepts everything becomes a catalogue rather than a set of decisions.

Version it properly. Semantic versioning, applied to design as well as code:

Change Bump Example
Fix within the contract patch Correcting a focus ring offset
Additive, backwards compatible minor A new ghost button variant
Breaking major Renaming a token, removing a variant, changing default spacing

Renaming a token is a breaking change. It is tempting to treat design values as cosmetic, but a renamed token breaks every consumer. Deprecate with an alias, announce, then remove in a major release.

Adoption is measurable, so measure it. Count the proportion of components in the product that come from the system, and count the hard-coded colour and spacing values that bypass tokens. A linter can enforce the second one:

// stylelint: forbid raw colours so tokens stay the only route
rules: {
  'declaration-property-value-disallowed-list': {
    'color': ['/^#/', '/^rgb/'],
    'background-color': ['/^#/', '/^rgb/'],
  },
}

Support the consumers. A changelog, a migration note per breaking change, and a place to ask questions. Most design systems fail through friction rather than technical deficiency: if using the system is slower than writing the CSS by hand, people will write the CSS by hand and be right to.


7. When not to build one

A design system has a real and continuing cost. It is frequently the wrong investment.

Do not build one when:

  • The product is still finding its shape. Systematising decisions you are about to discard slows you down twice.
  • There is one product, one designer and one developer. A shared stylesheet and a token file is the whole system you need, and it is enough.
  • Nobody will own it. An unowned system is worse than none, because it looks authoritative while being wrong.
  • An existing system would do. Adopting Material, Carbon, Fluent, shadcn/ui or Bootstrap and theming it through tokens is a legitimate and often better answer than building from zero. You inherit the accessibility work, which is the expensive part.

The honest minimum, and it covers a surprising number of projects: a token file, a documented type and spacing scale, and half a dozen components that carry their own accessibility. Start there and grow only on evidence of repeated duplication.

The signals that you have outgrown the minimum: three different buttons in the product, two teams disagreeing about spacing in review, a rebrand that requires touching hundreds of files, or a second platform arriving.


8. Systems worth reading

Existing systems are free, thoroughly documented design research. Read them for the reasoning, not to copy their look.

System Owner Worth it for
Material Design 3 Google The most complete token taxonomy and colour-role model anywhere; dynamic colour
Human Interface Guidelines Apple Platform conventions, touch target sizing, restraint
Carbon IBM Enterprise data density, tables, and an unusually good accessibility section
Fluent 2 Microsoft Cross-platform tokens and a well-argued layering model
Polaris Shopify Content and tone guidance integrated with components, which most systems separate
Atlassian Design System Atlassian Practical component documentation and clear do/do-not examples
GOV.UK Design System UK government The best-evidenced patterns in existence, each with published research; accessibility-first
Primer GitHub A real system evolving under a large legacy product
shadcn/ui Community The copy-into-your-repo model: Radix behaviour plus Tailwind tokens, owned by you

GOV.UK deserves particular attention if you only read one. Every pattern cites the user research behind it, including patterns they tried and abandoned, which is rare and more instructive than a finished specification.

Note the trade-off in the shadcn model. Copying components into your repository gives full ownership and no version upgrades, which is excellent until a fix lands upstream and you have to port it yourself.


9. How design systems fail

  • Built as a component catalogue with no usage guidance. Teams then use the wrong component correctly.
  • Tokens named for appearance, so the first theme change invalidates half the names.
  • Components consuming tier 1 directly, which silently opts them out of theming.
  • No owner. Drift, forks, three buttons.
  • Adoption assumed rather than measured. The system exists, the product does not use it, and nobody knows because nobody counted.
  • Too rigid too early. A system with no escape hatch gets bypassed entirely, which is worse than a system with a monitored one.
  • Figma and code out of sync, so neither is trusted and every ticket starts with a question about which is right.
  • Accessibility left to consumers. The single biggest missed opportunity: a component library is the one place where fixing focus handling once fixes it everywhere.
  • Documentation that describes the component rather than the decision. A page showing four button variants without saying when to use each is a screenshot, not documentation.
  • A rebrand that turns out to require code changes everywhere, which is the proof that the token indirection was never really in place.

Where this goes next

On the implementation side: CSS for custom properties, Bootstrap for an existing themeable system, React Overview and React Libraries for component composition, and npm for publishing and versioning the package itself.


Back to top