All docs
Everyone· 5 min

Working with tokens

Primitive vs semantic, the only-touch-the-semantics rule, the four tokens per status color.

A token is a named value. Instead of "the blue we use for primary buttons" being #1464FF scattered across 47 files, it's --primary defined in one place. Change the definition, every reference updates.

That's the whole idea. The rest of this doc is how we structure tokens so that idea actually works at scale.

Two layers: primitives and semantics

We have two token layers stacked on each other.

Layer 1 — Primitives (the raw palette)

These are the ramps. Six color families × 11 steps each. They're named by what they are, not what they do:

--primary-50 … --primary-950
--neutral-50 … --neutral-950
--error-50 … --error-950
--success-50 … --success-950
--warning-50 … --warning-950
--info-50 … --info-950

Plus type sizes, spacing values, radii, shadows.

These are the vocabulary. They aren't supposed to be used directly in components.

Designer note: This is your color palette in Figma — every shade you have to choose from, named neutrally.

Layer 2 — Semantics (meaning-based)

These are the assignments. They map a primitive step to a role:

--primary           → primary-500   (the actual brand color we use)
--primary-hover     → primary-600   (what primary darkens to on hover)
--primary-foreground → neutral-50    (white text on primary)
--background        → neutral-50    (page background)
--foreground        → neutral-950   (default text)
--border            → neutral-200   (1px dividers)
--ring              → primary-500   (focus rings — always brand)

Plus full sets for every status (success / error / warning / info), each with DEFAULT, -hover, -active, -foreground, -subtle, -border, -ink.

These are what components actually use.

Designer note: This is your "primary action" and "default text" labels — the purpose a color serves, not the color itself.

The rule: components only touch semantics

A wrapper file should never reference a primitive directly. Inside components/upnext/button.tsx you should never see bg-primary-500. You should see bg-primary.

Why this matters:

  • If the brand color shifts to a new blue, we change one semantic mapping (--primary: var(--primary-500) → --primary: var(--primary-400)) and every component updates.
  • If we ever need to retheme — different brand, different mode, accessibility variant — we swap the semantic layer without touching a single component file.
  • It forces you to think about role instead of shade when designing. "This is the destructive action" not "this is the red button."

The only place primitives appear directly is on the Foundations page, where we display them as swatches. That page is documentation about the system, not part of the system.

How to think about color when designing

When you reach for a color, ask: what role does this serve? That tells you the semantic token.

You want…Reach for…
The default page backgroundbg-background
Body texttext-foreground
The brand action (primary button)bg-primary / text-primary
A muted captiontext-muted-foreground
A destructive actionbg-destructive (alias of bg-error)
A success notice backgroundbg-success-subtle
The text inside a success noticetext-success-ink
A divider lineborder-border
A focus ringring-ring

Notice the pattern: status colors have a -subtle bg + matching -ink text + matching -border. That triplet is how every alert / badge / chip is built.

The four tokens per status color:

Token suffixWhen to useExample
(none)Solid, attention-getting fillA "Save" button, a status dot
-subtleTinted background, low-noiseAn alert, a badge background
-borderBorder on a -subtle backgroundThe 1px line around a soft alert
-inkText on a -subtle background"Build failed" copy inside a red alert
-foregroundText on the solid colorThe white "Save" label on a blue button

How to think about size, space, radius

Same pattern. We use token names for these too:

  • Spacing: Tailwind's gap-2, p-3, mb-6, etc. — these map to --spacing values (4px base).
  • Radius: rounded-md, rounded-lg, rounded-xl — these map to our --radius-* scale, all derived from the base --radius (10px).
  • Type: every text size matches a step in our type scale (text-sm, text-base, text-lg…). Don't use text-[15px] arbitrary values — bring it back to the scale.

If you find yourself reaching for an arbitrary value, that's a signal: either the scale needs another step, or you're solving the wrong problem.

When to add a new token

Rarely. Most design problems are solved by combining existing tokens, not by adding new ones.

Add a new primitive step (e.g. a new shade) only if:

  • An existing step is being routinely "almost right but not quite" in multiple places
  • The need will recur — not a one-off for one screen

Add a new semantic token when:

  • You find yourself wanting to alias an existing primitive for clarity (e.g. --tab-active-bg referencing --primary-subtle — useful if "active tab" might want to diverge from "primary subtle bg" later)
  • A new component category needs its own semantic name to keep the system grokkable

When in doubt, ask in the DS channel before adding. Tokens are a public API — once everywhere references them, removing them is painful.

Where to find them

  • Token definitions (the live values): app/globals.css — the :root block has all primitives + semantics, .dark block remaps semantics for dark mode.
  • Token metadata (names, hues, intent — used to render the Foundations page): lib/tokens.ts.
  • Token utilities (the bg-primary, text-foreground Tailwind classes): generated from the @theme inline block in globals.css.

If you change a token value, keep globals.css and lib/tokens.ts in sync. They're two views of the same truth.