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.
primary
800 · #363636The white-label hook. Every primary action derives from this scale — swap it to rebrand the whole system.
- • Primary buttons & key CTAs (800)
- • Links and active states (600)
- • Hover / active steps (900 / 950)
neutral
50 · #f8fafcA cool blue-gray (slate) that does double duty — the surfaces you read on (page, cards, popovers) and the text on them (foreground, muted). Named neutral, not surface, because it's both.
- • Page, card & popover backgrounds (50)
- • Foreground & muted text (600+ / 950)
- • Muted / secondary surfaces (100)
control
100 · #f4f4f5The grays for the lines and states around controls — input borders, dividers, the focus ring, disabled. Kept separate from the neutral ramp so a border never blends into the surface it sits on.
- • Input borders & dividers (200)
- • Focus ring (400)
- • Disabled surface & text (100 / 500)
red
50 · #fef2f2Drives both error (a state) and destructive (an action).
- • Error & destructive surfaces (600)
- • Subtle error tints (50)
- • Destructive hover / active (700 / 800)
green
50 · #f0fdf4Positive outcomes — success states and confirmations.
- • Success surfaces (700, AA on white)
- • Subtle success tints (50)
- • Success text on a tint (700)
amber
50 · #fffbebCaution without alarm. Needs a dark foreground — amber can't carry white text.
- • Warning surfaces (500, dark text)
- • Subtle warning tints (50)
- • Warning text on a tint (700)
blue
50 · #eff6ffInformational accents — tips, 'new' markers, info states.
- • Info surfaces (600)
- • Subtle info tints (50)
- • Info hover (700)
Accessibility
#ffffff · #000000Pure #fff and #000, outside the themed ramps. Reach for them only when a semantic token can’t hit the contrast you need.
- • Maximum-contrast text on dark or colored surfaces
- • Forced high-contrast mode foreground
- • Knockout text over photography
- • AAA body text on light surfaces
- • Crisp 1px hairlines and rules
- • Scrims behind dialogs and sheets
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.
The outermost base layer the whole app sits on. Pure white in light, near-black slate in dark.
- • App shell / body background
- • The backdrop pages float on
- • Behind every surface
The page surface that sits on the canvas.
- • Body background
- • Hero section background
- • Default layout color
Surface color for cards sitting on the page background.
- • Article cards in a feed
- • Stat tiles
- • Profile boxes
Surface for anything that floats above the page — small or large.
- • Dialogs & drawers / sheets
- • Dropdown menus & command palette
- • Tooltips & date pickers
Quieter surface for non-active areas.
- • Sidebar background
- • Table row hover
- • Code block background
The one surface + text combo used for anything non-interactive. Reach for it on any disabled control.
- • Greyed-out 'Submit' button
- • Disabled menu item
- • Read-only input field
Filled — the main affirmative action.
- • 'Save' / 'Continue' button
- • Primary CTA
- • Active tab indicator
Filled primary under the cursor.
- • Button on hover
Filled primary while pressed.
- • Button mid-click
Low-emphasis filled — a light brand tint + brand text. (Looks grey until you rebrand primary.)
- • Low-emphasis primary action
- • Selected filter chip
- • Active nav item background
Subtle primary under the cursor.
- • Subtle button on hover
Subtle primary while pressed.
- • Subtle button mid-click
No fill — primary text on the surface; faint tint on hover. Lowest-emphasis primary.
- • Tertiary toolbar action
- • 'Learn more' link-button
Ghost primary under the cursor — a faint brand tint appears.
- • Ghost button on hover
Ghost primary while pressed — a slightly stronger tint.
- • Ghost button mid-click
The neutral secondary action — quiet, color-free.
- • 'Cancel' button
- • Secondary toolbar action
- • Filter chips
Secondary under the cursor.
- • Cancel on hover
Secondary while pressed.
- • Cancel mid-click
No fill — plain text on the surface; neutral tint on hover. The tertiary text button.
- • 'Skip' / 'Maybe later'
- • Overflow-menu items
- • Icon-only toolbar buttons
Ghost neutral under the cursor — a faint neutral tint.
- • Text button on hover
Ghost neutral while pressed.
- • Text button mid-click
Filled — highest-emphasis danger action.
- • 'Delete account' confirm
- • Primary danger CTA in a dialog
Filled destructive under the cursor.
- • Delete button on hover
Filled destructive while pressed.
- • Delete mid-click
Low-emphasis filled — a quiet delete/remove.
- • 'Remove' in a list row
- • Secondary danger action
Subtle destructive under the cursor.
- • 'Remove' on hover
Subtle destructive while pressed.
- • 'Remove' mid-click
No fill — destructive text on the surface; tint on hover. Lowest-emphasis danger.
- • Tertiary 'Delete' in a menu
- • Icon-only remove
Ghost destructive under the cursor — a faint red tint.
- • Ghost delete on hover
Ghost destructive while pressed.
- • Ghost delete mid-click
A form control at rest — the fill used for any input, select, checkbox or toggle.
- • Text field background
- • Checkbox / toggle track
- • Segmented control
A control under the cursor.
- • Field on hover
- • Hovered toggle
- • Hovered segment
A control while pressed or switched on.
- • Pressed segment
- • Checked toggle track
- • Active field
State color signalling something went wrong.
- • Invalid form field border
- • System-error toast
- • Failed-validation icon
Soft tint for inline error messages.
- • Inline alert banner
- • Validation hint background
- • Error callout bg
Affirmative color signaling a positive outcome.
- • 'Saved' toast
- • Success badge
- • Completed checkmark
Soft tint for inline success messages.
- • 'Profile updated' banner
- • Confirmation note background
Cautionary color — 'pay attention but don't panic'.
- • Unsaved changes bar
- • Expiring-soon badge
- • Warning toast
Soft tint for inline warnings.
- • Caution callout background
- • Soft warning banner
Neutral informational color.
- • 'New' badge
- • Info toast
- • Tip callout accent
Soft tint for inline informational notes.
- • Tip callout background
- • 'Did you know' panel
Border + ring
Hover or tap a card to see its use cases and the primitive + hex behind it.
control-200 · #e4e4e7Default border for surfaces and dividers.
- • Card outline
- • Section divider line
- • Default input border
control-300 · #d4d4d8Emphasized border for higher-contrast outlines.
- • Selected card outline
- • Hovered card border
- • Important separator
control-200 · #e4e4e7Border color specifically for input fields.
- • Text field at rest
- • Select dropdown border
- • Textarea outline
control-300 · #d4d4d8Input border on hover, signaling interactivity.
- • Text field under the mouse
- • Hovered combobox
- • Hovered date input
control-400 · #a1a1aaFocus ring for keyboard navigation.
- • Focused button outline
- • Focused input ring
- • Focused link halo
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 editRaw ramps — every color, spacing and radius value. Change a hex or add a step here. Feeds the build.
tokens/semantic-light.jsonYou editWires primitives → roles (background, primary, control…) for light mode. Re-point a role here. Feeds the build.
tokens/semantic-dark.jsonYou editThe same roles, dark-mode values. Feeds the build.
scripts/build-tokens.mjsRarelyThe compiler. Reads the three JSON files above and writes tokens.css. You run it; you rarely change it.
tokens/tokens.cssGenerated · don't touchWritten 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 editThe 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.
Components consume semantic tokens (background, primary, error) — never a primitive step or a raw hex.
neutral (slate) = surfaces + text you read on. control (zinc) = borders, dividers, ring, disabled. A border never matches its surface.
canvas → background → card → popover. Dialogs, drawers and sheets are popover — there's no separate modal token.
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 and outline share the same tokens — outline just draws a border. It's a component choice, not a new token.
destructive is an action (a button that deletes). error is a state (a message or badge). Cancel is neutral — never red.
error / success / warning / info are tonal chips for badges, banners and toasts. States get no hover/active — use primary/destructive for colored actions.
disabled + disabled-foreground on anything non-interactive. Don't invent a per-component gray.
Every bg/fg pair is AA (≥4.5:1). accessibility white/black is a max-contrast fallback only.
Swap primary to rebrand; swap neutral to change temperature. Nothing else moves.