Design Tokens and Theming
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.
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());
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.
| What developers do | What 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 |
- 1Build tokens in three layers: primitive (raw values) → semantic (purpose) → component (scoped API)
- 2Components should only reference semantic or component tokens, never primitives directly
- 3Dark mode swaps semantic token values — the token names stay the same
- 4Use a blocking
<head>script to prevent theme flash on page load - 5Use color-scheme: light dark to automatically adapt browser-native UI (scrollbars, form controls)