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.”

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.