TLDR: Primitive tokens store raw values ($color-red-500, $space-8). Semantic tokens reference primitives by purpose ($color-error, $space-inset). Use primitives as your single source of truth. Use semantics as your API to components. The bridge between them is a mapping layer.
The Two Layers
Every design token system has two conceptual layers, whether you call them that or not:
Primitive tokens — the raw materials:
--color-red-500: #EF4444;
--color-blue-500: #3B82F6;
--space-4: 4px;
--space-8: 8px;
These are absolute values. They never depend on context, theme, or component.
Semantic tokens — the meaning:
--color-error: var(--color-red-500);
--color-link: var(--color-blue-500);
--color-link-visited: var(--color-purple-600);
--space-inset-sm: var(--space-8);
These express intent. If error colors change from red to orange, you update one mapping, not every error state across 200 components.
Why the Separation Matters
Without this split, you get one of two failure modes:
All primitives, no semantics — Components hardcode var(--color-red-500) directly. When the brand refreshes, you’re searching through every file to find what “red-500” was being used for.
All semantics, no primitives — Every color is --color-error, --color-warning, --color-info. When you need --color-error at 90% opacity for a disabled state, you can’t derive it because the raw red value is buried behind a semantic name.
The Mapping Layer
The critical insight: primitives are your source of truth, semantics are your API surface. Components should only reference semantic tokens. Primitives should only be referenced by semantic mappings.
Primitives: --color-red-500 --color-red-400 --color-blue-500
↓ ↓ ↓
Semantics: --color-error --color-warning --color-link
↓ ↓ ↓
Components: Button Alert Link
This means:
- Changing a primitive value updates all semantics that reference it automatically
- Adding a new theme (dark mode) creates new primitive values, maps them to the same semantic names
- Components never need updating when the palette shifts
When to Break the Rules
There are two exceptions where components should reference primitives directly:
1. Data visualization. Charts use color to encode data, not meaning. --color-red-500 means “this series is the first dataset,” not “this is an error.”
2. Utility classes. If you expose a text-red-500 utility, it’s intentionally bypassing semantics for rapid prototyping.
Implementation Style
In a token spec format, the split looks like:
colors:
# Primitives
red-50: "#FEF2F2"
red-500: "#EF4444"
blue-500: "#3B82F6"
blue-600: "#2563EB"
# Semantics
error: "{colors.red-500}"
error-bg: "{colors.red-50}"
link: "{colors.blue-500}"
link-hover: "{colors.blue-600}"
The {...} reference syntax makes the mapping explicit and machine-parsable. A linter can enforce that no component references red-500 directly — only error or link.
The Takeaway
Primitives are your foundation; semantics are your structure. Without both, your design system is either brittle (hardcoded values everywhere) or opaque (missing the raw materials to do something new). The mapping layer between them is the actual architecture work.
