Hero image for Primitive vs Semantic Tokens — When to Use What

Primitive vs Semantic Tokens — When to Use What

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.” Applying a semantic name like --color-error to a chart series would mislead both developers and the design system.

2. Utility classes. If you expose a text-red-500 utility, it’s intentionally bypassing semantics for rapid prototyping. Utility-first frameworks like Tailwind CSS operate this way by design — though they also provide semantic variants (text-error, bg-warning) as opt-ins.

Real-World Example: Theming

Consider a dark mode implementation. Primitives change per theme; semantics stay fixed:

# Light theme primitives
bg-base: "#FFFFFF"
text-primary: "#111827"

# Dark theme primitives  
bg-base: "#0F172A"
text-primary: "#F1F5F9"

# Semantics (unchanged across themes)
surface-primary: "{bg-base}"
content-primary: "{text-primary}"

Components reference surface-primary and content-primary — they automatically adapt to whichever theme is active. This is the practical payoff of the primitive/semantic split: one mapping change per token per theme, zero component changes.

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.