Skip to content

Design Tokens and Theming

advanced12 min read

Tokens Are the Single Source of Truth

Design tokens are named values that represent design decisions — colors, spacing, typography, shadows, radii. They're not just CSS variables. They're a contract between design and engineering: when a designer changes the primary blue, every component using that token updates automatically. Without tokens, design consistency relies on human memory. With tokens, it's enforced by the system.

Mental Model

Think of design tokens as a three-layer power grid. The power plant (primitive tokens) generates raw energy (raw color values, pixel numbers). Substations (semantic tokens) transform that energy for specific purposes (primary-action color, body-text size). Appliances (component tokens) draw from the substation for their specific needs (button background, input border). Change the power plant output, and every appliance adapts through the chain.

Token Hierarchy

Layer 1: Primitive Tokens (Raw Values)

:root {
  /* Colors — raw palette */
  --gray-50: oklch(0.97 0 0);
  --gray-100: oklch(0.93 0.005 240);
  --gray-200: oklch(0.87 0.008 240);
  --gray-300: oklch(0.78 0.01 240);
  --gray-400: oklch(0.65 0.015 240);
  --gray-500: oklch(0.55 0.02 240);
  --gray-600: oklch(0.45 0.02 240);
  --gray-700: oklch(0.35 0.018 240);
  --gray-800: oklch(0.25 0.015 240);
  --gray-900: oklch(0.17 0.012 240);
  --gray-950: oklch(0.10 0.01 240);

  --blue-500: oklch(0.55 0.19 240);
  --blue-600: oklch(0.48 0.19 240);
  --red-500: oklch(0.55 0.20 25);
  --green-500: oklch(0.60 0.18 145);

  /* Spacing — raw scale */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-12: 3rem;
  --space-16: 4rem;

  /* Radius */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-xl: 16px;
  --radius-full: 9999px;
}

Primitive tokens are context-free. --blue-500 says nothing about how it's used.

Layer 2: Semantic Tokens (Purpose-Driven)

:root {
  /* Backgrounds */
  --color-bg: var(--gray-50);
  --color-bg-surface: white;
  --color-bg-muted: var(--gray-100);
  --color-bg-inverse: var(--gray-900);

  /* Text */
  --color-text: var(--gray-900);
  --color-text-secondary: var(--gray-600);
  --color-text-muted: var(--gray-400);
  --color-text-inverse: white;

  /* Actions */
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-600);
  --color-danger: var(--red-500);
  --color-success: var(--green-500);

  /* Borders */
  --color-border: var(--gray-200);
  --color-border-strong: var(--gray-300);

  /* Shadows */
  --shadow-sm: 0 1px 2px oklch(0 0 0 / 0.05);
  --shadow-md: 0 4px 8px oklch(0 0 0 / 0.08);
  --shadow-lg: 0 12px 24px oklch(0 0 0 / 0.12);

  /* Typography */
  --font-sans: 'Inter', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;
}

Semantic tokens describe purpose, not value. --color-primary might map to blue today and purple tomorrow.

Layer 3: Component Tokens (Scoped)

.button {
  --btn-bg: var(--color-primary);
  --btn-text: white;
  --btn-padding-x: var(--space-6);
  --btn-padding-y: var(--space-3);
  --btn-radius: var(--radius-md);
  --btn-shadow: var(--shadow-sm);

  background: var(--btn-bg);
  color: var(--btn-text);
  padding: var(--btn-padding-y) var(--btn-padding-x);
  border-radius: var(--btn-radius);
  box-shadow: var(--btn-shadow);
}

.button--danger {
  --btn-bg: var(--color-danger);
}

.button--ghost {
  --btn-bg: transparent;
  --btn-text: var(--color-primary);
  --btn-shadow: none;
}

Component tokens create a customization API. Override --btn-bg from outside without knowing the component's internals.

Dark Mode Implementation

Strategy 1: Swap Semantic Tokens

/* Light theme (default) */
:root {
  --color-bg: var(--gray-50);
  --color-bg-surface: white;
  --color-text: var(--gray-900);
  --color-text-secondary: var(--gray-600);
  --color-border: var(--gray-200);
  --shadow-md: 0 4px 8px oklch(0 0 0 / 0.08);
}

/* Dark theme — same semantic names, different values */
[data-theme="dark"] {
  --color-bg: var(--gray-950);
  --color-bg-surface: var(--gray-900);
  --color-text: var(--gray-100);
  --color-text-secondary: var(--gray-400);
  --color-border: var(--gray-700);
  --shadow-md: 0 4px 8px oklch(0 0 0 / 0.3);
}

/* Respect system preference as default */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg: var(--gray-950);
    --color-bg-surface: var(--gray-900);
    --color-text: var(--gray-100);
    --color-text-secondary: var(--gray-400);
    --color-border: var(--gray-700);
    --shadow-md: 0 4px 8px oklch(0 0 0 / 0.3);
  }
}

Strategy 2: color-scheme Property

:root {
  color-scheme: light dark; /* Tell browser both are supported */
}

[data-theme="light"] { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }

color-scheme automatically adjusts scrollbar colors, form control colors, and default background/text colors to match the theme.

Theme Switching with JavaScript

function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
}

function getInitialTheme() {
  const stored = localStorage.getItem('theme');
  if (stored) return stored;
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark' : 'light';
}

// Apply on page load (prevent flash)
document.documentElement.setAttribute('data-theme', getInitialTheme());
Quiz
A button component uses background: var(--blue-500) directly. What breaks when you implement dark mode?
Common Trap

The dark mode flash problem: if theme detection runs in a React component, the server-rendered HTML has no theme set. The user sees a flash of the wrong theme on load. Fix: inject a blocking <script> in the <head> that sets the data-theme attribute before any content renders. Next.js does this with a script in the root layout.

Execution Trace
Primitive
--blue-500: oklch(0.55 0.19 240)
Raw value — context-free
Semantic
--color-primary: var(--blue-500)
Purpose-driven — 'this is the primary action color'
Component
.button { --btn-bg: var(--color-primary) }
Scoped to component — customizable API
Dark mode
[data-theme='dark'] { --color-primary: var(--blue-400) }
Swap semantic token values — all components update
Result
Every button background updates automatically
Change propagates through the token chain
What developers doWhat they should do
Using primitive tokens directly in components (background: var(--blue-500))
Using primitives directly means dark mode requires finding and swapping every blue-500 reference. Semantic tokens swap once.
Always go through semantic tokens (background: var(--color-primary))
Defining dark mode colors separately from the token system
Separate dark mode variables double your maintenance. Swapping semantic tokens is a single override.
Dark mode should swap semantic token values, not define entirely new variables
Detecting theme in a React component (causes flash of wrong theme)
React hydration happens after the initial paint. A component-level detection shows the wrong theme first.
Use a blocking script in `<head>` that reads localStorage and sets data-theme before render
Creating tokens for every possible value
Over-tokenizing creates a maze of indirection. border-radius: 3px on one element doesn't need a token unless 3px is a system-wide decision.
Create tokens only for values that represent design decisions and need to be consistent
Quiz
Why should components use semantic tokens instead of primitive tokens?
Quiz
How do you prevent the flash of wrong theme on page load?
Key Rules
  1. 1Build tokens in three layers: primitive (raw values) → semantic (purpose) → component (scoped API)
  2. 2Components should only reference semantic or component tokens, never primitives directly
  3. 3Dark mode swaps semantic token values — the token names stay the same
  4. 4Use a blocking <head> script to prevent theme flash on page load
  5. 5Use color-scheme: light dark to automatically adapt browser-native UI (scrollbars, form controls)