The three layers
Think of design tokens as a stack. Each layer has a specific job, and each layer only talks to the one directly below it.
/* LAYER 1: Primitives */
/* Raw values. No meaning. Just the palette. */
--indigo-50: #EEF2FF;
--indigo-500: #6366F1;
--indigo-600: #4F46E5;
--zinc-50: #FAFAFA;
--zinc-100: #F4F4F5;
--zinc-800: #27272A;
--zinc-900: #18181B;
--zinc-950: #09090B;
/* LAYER 2: Semantic */
/* Purpose-driven names. References primitives. */
--color-bg: var(--zinc-50);
--color-surface: var(--zinc-100);
--color-text: var(--zinc-900);
--color-muted: var(--zinc-500);
--color-accent: var(--indigo-500);
--color-accent-hover: var(--indigo-600);
--color-border: var(--zinc-200);
/* LAYER 3: Component */
/* UI-specific. References semantic tokens. */
--button-bg: var(--color-accent);
--button-bg-hover: var(--color-accent-hover);
--button-text: white;
--card-bg: var(--color-surface);
--card-border: var(--color-border);
--input-bg: var(--color-bg);
--input-border: var(--color-border);
--input-ring: var(--color-accent);Each layer has a clear boundary. Primitives never appear in component styles. Component tokens never reference primitives directly. The semantic layer is the bridge.
Layer 1: Primitives
Primitive tokens are your raw materials. Hex codes, pixel values, font stacks, easing functions. They are named after what they are, not what they do. --indigo-500 is an indigo. --space-4 is 16px. --radius-lg is 12px.
Primitives are the only layer where raw values (hex, px, rem) appear. Everything above is a reference. This constraint is what makes the system auditable. If you need to know every place a specific hex code is used, you check one place: the primitive definition.
/* Primitive color scale */
--brand-50: #F0F4FF;
--brand-100: #DAE2FF;
--brand-200: #BCC8FF;
--brand-300: #96A6FF;
--brand-400: #7080FF;
--brand-500: #4F5AFF;
--brand-600: #3B43E0;
--brand-700: #2E34B8;
--brand-800: #242990;
--brand-900: #1A1E6C;
--brand-950: #0F1148;
/* Primitive spacing scale */
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;Why primitives matter
Without primitives, your semantic tokens contain hardcoded values. --color-accent: #6366F1 looks fine until you realize that same hex appears in --color-accent, --color-link, and --color-focus-ring. If the brand color changes, you need to find and update all three. With primitives, all three reference var(--indigo-500), and you update one line.
Layer 2: Semantic tokens
The semantic layer is where meaning is assigned. A primitive says "this is indigo at 500 lightness." A semantic token says "this is the accent color." The distinction unlocks everything: theming, dark mode, multi-brand support, and AI comprehension.
The semantic layer is also the theming boundary. To switch from light to dark mode, you reassign the semantic layer to different primitives. Components never change. For a deep dive into semantic naming, read about design tokens for AI.
/* Light theme: semantic → primitive mapping */
:root {
--color-bg: var(--zinc-50);
--color-surface: var(--zinc-100);
--color-text: var(--zinc-900);
--color-accent: var(--brand-500);
}
/* Dark theme: same semantic names, different primitives */
.dark {
--color-bg: var(--zinc-950);
--color-surface: var(--zinc-900);
--color-text: var(--zinc-50);
--color-accent: var(--brand-400);
}How many semantic tokens do you need?
For color, most products need 12 to 18 semantic tokens. Background, surface (with hover), text (with muted), border, accent (with hover and soft), and three state colors (success, warning, error). If you have 40+ semantic tokens, you are likely encoding component-level decisions at the wrong layer.
Layer 3: Component tokens
Component tokens are the most specific layer. They answer: "What does this specific UI element look like?" A button has a background, text color, border, and focus ring. Each of those references a semantic token.
/* Button component tokens */
--button-primary-bg: var(--color-accent);
--button-primary-bg-hover: var(--color-accent-hover);
--button-primary-text: var(--color-text-inverted);
--button-primary-ring: var(--color-accent);
/* Card component tokens */
--card-bg: var(--color-surface);
--card-border: var(--color-border);
--card-shadow: var(--shadow-md);
/* Input component tokens */
--input-bg: var(--color-bg);
--input-border: var(--color-border);
--input-border-focus: var(--color-accent);
--input-text: var(--color-text);
--input-placeholder: var(--color-muted);Are component tokens always necessary?
No. For small projects or early-stage products, two layers (primitives + semantic) are often enough. Component tokens become valuable when you have a complex component library, multiple teams consuming the same tokens, or when you need to override individual component styles without affecting the global theme.
The rule of thumb: if you find yourself writing var(--color-accent) in a button component and wishing you could change it for just buttons, you need a component token.
How the layers interact
The critical rule is: each layer only references the one below it. Component tokens reference semantic tokens. Semantic tokens reference primitives. Primitives hold raw values.
This means three types of changes are each handled at exactly one layer:
Brand color change (new hex value): Update the primitive. --brand-500: #6366F1 becomes --brand-500: #7C3AED. Every semantic and component token that references it updates automatically.
Theme switch (light to dark): Reassign the semantic layer. --color-bg points to a different primitive. Components don't change at all.
Component redesign (button gets a new style): Update the component token. --button-primary-bg could reference a different semantic token or even a new primitive. Nothing else in the system is affected.
Real-world examples
Shopify Polaris
Polaris uses a three-layer architecture with primitives (color scales), semantic aliases (surface, text, decorative), and component-specific tokens. Their --p-color-bg-surface is a semantic token. Their --p-color-bg-surface-secondary adds specificity for nested surfaces.
Material Design 3
Material uses "tonal palettes" as primitives (primary0 through primary100), "color roles" as semantic tokens (primary, onPrimary, primaryContainer), and component tokens per widget (buttonPrimary, chipOutline). The tonal palette is generated algorithmically from a single seed color.
SeedFlip seeds
Each SeedFlip seed provides two layers out of the box. Primitives (the raw hex values in the palette) and semantic tokens (bg, surface, text, accent, muted, border, and all derived variants). You add the third layer (component tokens) based on your specific UI components. This approach is explained in detail in CSS custom properties explained.
Building the architecture yourself
Start with semantics, not primitives. Define the roles your interface needs first, then create the primitive palette to fill them.
/* Step 1: Define the roles you need */
--color-bg
--color-surface
--color-surface-hover
--color-text
--color-muted
--color-accent
--color-accent-hover
--color-accent-soft
--color-border
--color-success
--color-warning
--color-error
/* Step 2: Create primitives to satisfy them */
/* Step 3: Wire primitives → semantic → components */This roles-first approach prevents primitive bloat. You only create the palette entries you actually need, and every primitive has at least one semantic consumer. No orphan tokens. For the complete list of tokens most products need, see design variables that actually matter.
The three-layer token architecture is not academic theory. It is the pattern that every mature design system converges on because it solves the three most common scaling problems: brand changes, theme switches, and component redesigns. Each happens at exactly one layer. Nothing bleeds across boundaries. Start with the semantic layer, build primitives to support it, and add component tokens when your component library demands it.