Skip to content

Design Tokens With CSS Custom Properties

Token taxonomy for color, space, type, and radius: CSS custom properties as the contract between design intent and implementation.

5 min read
Design Tokens
CSS Variables
Custom Properties
Theming
Frontend

CSS architecture is a bet on where class names live and who owns tokens. Here is the ownership half: what a token is, how to name the taxonomy, and why CSS custom properties are the contract I still reach for across global CSS, Modules, and utility-first stacks.

Without tokens, every architecture fails the same way, magic values multiply until "change the brand blue" is a grep lottery.

What a token is (and is not)

A design token is a named decision: "this is the foreground color," "this is the spacing step for dense UI," "this is the radius for controls." It is not every literal that appears in CSS. Hard-coding 1px for a hairline border is fine. Hard-coding #3b82f6 in twelve components is not.

I keep three layers in my head:

  1. Primitive: raw values (blue-500, space-2, font-sans). No product meaning yet.
  2. Semantic: role in the UI (color-fg, color-danger, space-stack). Themeable.
  3. Component: rare; only when a component owns a private decision (--button-height). Prefer semantic unless the component is a published package with a stable API.

Most teams skip primitives and dump hex into semantic names. That works until dark mode and then you rewrite half the sheet. Primitives make themes cheap.

Taxonomy I actually ship

I start with four families. Expand only when review pain forces it.

FamilyExamplesRule
Color--color-fg, --color-bg, --color-border, --color-accentSemantic first; primitives in a separate block
Space--space-1--space-8 (scale)Prefer scale over ad-hoc 13px
Type--font-sans, --text-sm, --leading-normalSize + family + line-height as related tokens
Radius--radius-sm, --radius-md, --radius-fullFew steps beat infinite taste

Motion, elevation, and z-index get tokens when the product has a real system - not on day one of a marketing site.

css
/* styles/tokens.css, illustrative @acme baseline */ :root { /* primitives */ --blue-500: #3b82f6; --gray-50: #f9fafb; --gray-950: #0a0a0a; --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; --radius-sm: 0.25rem; --radius-md: 0.375rem; /* semantic, themes swap these, not every component */ --color-fg: var(--gray-950); --color-bg: #ffffff; --color-muted: #6b7280; --color-border: #e5e7eb; --color-accent: var(--blue-500); --color-accent-fg: #ffffff; }

Consumers should prefer --color-fg over --gray-950. The primitive exists so dark mode can remap semantics without hunting component files.

CSS variables as the contract

I treat custom properties as a public API:

  • Document which names are stable.
  • Prefer semantic names in components and utilities.
  • Change primitives freely; treat semantic renames as breaking.
  • Never invent a one-off --that-modal-padding-we-liked without promoting it into the scale.

Utility-first stacks (Tailwind v3 or v4, available since Jan 2025) still need this contract, theme config or CSS-first @theme is just another way to publish the same names. Modules and global CSS read the same variables. The architecture post's hybrid works because tokens are global.

tsx
// components/Badge.tsx, consume the contract, do not invent hex export function Badge({ children }: { children: React.ReactNode }) { return ( <span className="inline-flex items-center rounded-[var(--radius-sm)] px-[var(--space-2)] py-[var(--space-1)] text-sm" style={{ background: 'var(--color-accent)', color: 'var(--color-accent-fg)', }} > {children} </span> ); }

Yes, you can wire these through Tailwind theme keys instead of style, the point is the names, not the syntax.

Decision criteria: when to add a token

Add a token when:

  • The same value appears in three places and a designer named the intent
  • A theme (brand, density, dark) must swap the value without rewriting UI
  • A component library package needs a stable theming surface for consumers

Do not add a token when:

  • It is a one-off illustration layout
  • You are naming every step of a one-page prototype
  • The "token" is really a layout hack (--sidebar-was-240-now-280)

Dead tokens are worse than missing ones, they lie about the system.

Failure modes

Semantic names that are secretly primitives. --color-blue is not semantic. --color-accent is. When dark mode arrives, "blue" does not tell you what to invert.

Tokens without ownership. If anyone can add --space-13 in a PR, you do not have a system. Gate new tokens in review the way you gate public API.

Duplicating the scale in JS and CSS. One source of truth. If JS needs values (charts, canvas), generate or import from the same list, do not hand-maintain two dictionaries that drift.

Over-abstracting early. A 40-token sheet that nobody uses is ceremony. Ship the four families; grow under pressure.

Treating Tailwind v4 migration as "we finally have tokens." v4 changes the engine and CSS-first config story; it does not invent design intent. You still owe the taxonomy.

Continuity

This is the sequel to CSS architecture: tokens make hybrids coherent and make utility-first honest. Dark mode is where the semantic layer actually matters: system preference, explicit toggle, and persistence without a flash of the wrong theme.

Takeaway

Design tokens are named decisions, not a dump of hex codes. Put primitives under semantic roles, publish them as CSS custom properties, and treat those names as API, architecture only stays coherent if the contract is real.