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 background | bg-background |
| Body text | text-foreground |
| The brand action (primary button) | bg-primary / text-primary |
| A muted caption | text-muted-foreground |
| A destructive action | bg-destructive (alias of bg-error) |
| A success notice background | bg-success-subtle |
| The text inside a success notice | text-success-ink |
| A divider line | border-border |
| A focus ring | ring-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 suffix | When to use | Example |
|---|---|---|
| (none) | Solid, attention-getting fill | A "Save" button, a status dot |
-subtle | Tinted background, low-noise | An alert, a badge background |
-border | Border on a -subtle background | The 1px line around a soft alert |
-ink | Text on a -subtle background | "Build failed" copy inside a red alert |
-foreground | Text on the solid color | The 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--spacingvalues (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 usetext-[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-bgreferencing--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:rootblock has all primitives + semantics,.darkblock 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-foregroundTailwind classes): generated from the@theme inlineblock inglobals.css.
If you change a token value, keep globals.css and lib/tokens.ts in sync. They're two views of the same truth.