Richard Griner
← back
MethodologyDesign systemsColor

Building color in four layers

Color is built in four layers, bottom-up — the four levels in the animation above. A raw value is a bare hex like #0085CA, with no name and no job. A primitive gives it a name and a slot in a scale (primary-800, blue-500) — naming it is what lets you track and reuse it instead of pasting hexes. Semantic tokens map primitives to roles like background, primary, or error — meaning, not value. And components bind those roles to real UI (a button reads primary), which is where patterns and reuse come from. Each layer points only at the one beneath it, so a component never hard-codes a hex.

I worked with a team that had three brand colors: brand, brand-light, and brand-lighter. Then someone needed something a bit lighter than light. There was nowhere to put it, so they eyeballed a value and the palette drifted. Define the full ramps first and that doesn't happen — “lighter” already exists, so you reach for primary-200instead of inventing it. You make a thousand small decisions building a product. Settling the scale once removes a whole class of them. It also lets you swap a brand color, or an entire neutral scale, without touching a single component. That matters most when you're shipping several products, or white-labeling one.

Heads up — the cards below expand for the full details (use cases, the primitive step, and the hex behind each token). On desktop they open on hover; on a touch screen, tap a card to open it.

Primitives

Named color scales. A primitive is a raw value given a name and a slot in a scale (primary-800) — that's what lets you reuse and track it. Each card shows the ramp's base color, what it's for, and all 11 steps.

Two neutral ramps, on purpose. Neutral is for the surfaces and text you read on. Controlis for the lines and states around them: borders, dividers, focus ring, disabled. Keeping them apart stops neutrals from blending — a disabled button shouldn't land on the same gray as the card behind it.

Semantic tokens

Named roles components consume, grouped by family. Hover or tap a card to expand its overview, use cases, and the primitive + hex behind bg/fg.

Canvas vs. surfaces.With one flat background you can't tell the app frame from the content, and cards float with nothing grounding them. Canvas is the fix: a true base layer (pure white in light, near-black in dark) that everything sits on. Page, card, and popover stack on top of it, each a small step apart, so content reads as raised sections instead of one flat sheet.

Ghost = outline.Every ghost tier uses the same tokens as an outline button — colored text on the surface, a tint on hover. The only difference is whether the component draws a border, so one token set serves both. Filled and subtle tiers have no border at all — they're solid surfaces, so don't add one.

Surfaces
Primary action
Neutral action · secondary
Destructive action
Control · form fields
Error
Success
Warning
Info

Border + ring

Hover or tap a card to see its use cases and the primitive + hex behind it.

Component layer

The top layer — where semantic roles get bound to real components. Nothing to show here; the components live on their own page. This is just the wiring.

This is where a role meets a component. primary + primary-foreground become the default button; destructive becomes the delete button; card becomes the card surface. The component asks for the role by name — it never sees a primitive step or a hex.

Two things fall out of that. Reuse: every button that asks for primaryshares one definition, so they stay identical without anyone keeping them in sync — change primary once and every primary button, link and active state moves with it, in a single edit. Patterns: because components speak in roles, a new one assembled from the same roles inherits the system for free.

When one component genuinely needs to differ, you have two levers. Re-point the role it uses — swap its palette — and it moves along with everything else on that role. Or override the token on that single component, when the difference really is local to it. Reach for the per-component override last; most “this one should be different” requests turn out to be a missing or mis-mapped role, not a true exception.

Documents & skills for the AI

The files that make up the kit — the tokens the app reads, and the rules an AI or engineer follows to build consistently.

tokens/primitives.jsonYou edit

Raw ramps — every color, spacing and radius value. Change a hex or add a step here. Feeds the build.

tokens/semantic-light.jsonYou edit

Wires primitives → roles (background, primary, control…) for light mode. Re-point a role here. Feeds the build.

tokens/semantic-dark.jsonYou edit

The same roles, dark-mode values. Feeds the build.

scripts/build-tokens.mjsRarely

The compiler. Reads the three JSON files above and writes tokens.css. You run it; you rarely change it.

tokens/tokens.cssGenerated · don't touch

Written by the build — the CSS variables components and Tailwind actually read. Editing it by hand does nothing: the next build overwrites it, so change the JSON instead.

Design.mdYou edit

The written rules an AI or engineer follows. Prose, not code — update it as the system grows.

How edits flow. The three JSON files are the source. build-tokens.mjs reads them and writes tokens.css— that's what components import.

To change a token: edit a JSON file, save it, then run pnpm tokens:build in the terminal. That regenerates tokens.css. (Saving the JSON alone isn't enough — the build is a manual command, and the running dev server hot-reloads once the CSS is rewritten.)

The AI reads Design.md for the rules — no build needed there.

Color rules
Never skip a layer

Components consume semantic tokens (background, primary, error) — never a primitive step or a raw hex.

Two neutrals, on purpose

neutral (slate) = surfaces + text you read on. control (zinc) = borders, dividers, ring, disabled. A border never matches its surface.

Surface stack

canvas → background → card → popover. Dialogs, drawers and sheets are popover — there's no separate modal token.

Action emphasis tiers

Each action carries default/hover/active. Primary & destructive: Filled → Subtle → Ghost. Neutral: Filled → Ghost. The secondary action is the neutral button, not a color.

Ghost = outline

Ghost and outline share the same tokens — outline just draws a border. It's a component choice, not a new token.

Destructive ≠ error

destructive is an action (a button that deletes). error is a state (a message or badge). Cancel is neutral — never red.

Status colors are states

error / success / warning / info are tonal chips for badges, banners and toasts. States get no hover/active — use primary/destructive for colored actions.

One disabled combo

disabled + disabled-foreground on anything non-interactive. Don't invent a per-component gray.

Keep pairs AA

Every bg/fg pair is AA (≥4.5:1). accessibility white/black is a max-contrast fallback only.

Rebrand = one token

Swap primary to rebrand; swap neutral to change temperature. Nothing else moves.