01 / Visual language
The loudest token a system ships.
Color carries brand, hierarchy, state, and meaning all at once — and it’s the first token a stakeholder will argue about. A color system is the structure that lets you change every value without changing a single component.
01 / Why a system
Color is a name, not a value.
A color system is mostly naming. Fifty-five primitives sit behind a dozen semantic names, and every component asks for --foreground, never for #0F0F14. The name is stable; the value behind it can change per theme, per brand, per rebrand, without touching a single button.
That indirection is the entire game. Three things follow from it: dark mode becomes a token swap instead of a redesign, accessibility audits cover the whole product at once, and a rebrand stops being a six-month project.
- 01
Dark mode becomes a token swap instead of a redesign.
- 02
Accessibility audits cover the whole product at once.
- 03
A rebrand stops being a six-month project.
02 / Token layers
Three layers. one direction.
Tokens stack. Primitives at the bottom are the raw palette. Semantic aliases in the middle bind primitives to a job. Component tokens at the top exist only when a semantic isn’t expressive enough. References point downward — never up, never sideways.
Top tokens reference middle tokens. Middle tokens reference primitives. Direction is one-way.
Primitive
The raw palette — every shade you'll ever ship. Named only by hue and step. Never referenced directly from a component.
blue-600neutral-100red-500Internal — system reference only
Semantic
Purpose-bound aliases pointing at primitives. The layer components actually consume. Swappable per theme without rewriting components.
foregroundmuteddestructivebrandEvery component in the library
Component
Only when a semantic token isn't expressive enough. Reserved for high-traffic components where the token is part of the API.
button-primary-bgcard-borderinput-focus-ringRare — usually 5–10 tokens total
03 / The scale
Eleven steps, perceptually even.
Each hue gets an 11-step lightness ramp from 50 (page wash) to 950 (dark surface). Modern systems generate ramps in OKLCH so steps look evenly spaced to the eye — HSL produces visible jumps in the middle and crushes detail at the ends.
97%
93%
86%
76%
64%
52%
44%
36%
28%
18%
12%
Lightness reads as an arithmetic ramp; the chroma stays constant at 0.18.
0 → 1 · perceived brightness. This is the axis a ramp walks.
0 → ~0.37 · colourfulness. 0 is grey, high is vivid.
0 → 360° · the colour wheel at fixed L and C.
OKLCH splits colour into lightness, chroma, and hue — and moving one leaves the others alone. That’s what makes an even ramp trivial: hold hue and chroma, walk lightness. HSL can’t promise that; its “lightness” shifts perceived hue as it moves.
Five hues × eleven steps = 55 primitives. Brand, neutral, success, warning, destructive. Anything else is a semantic alias.
Brand
--brand
Neutral
--neutral
Success
--success
Warning
--warning
Destructive
--destructive
04 / Semantic palette
Six roles do most of the work.
Components don’t ask for blue. They ask for --brand. Every role pairs a surface with its readable ink. Pairs travel together — never split.
--brand
Primary actions, links, focused emphasis
--foreground
Body text, headings, default ink
--muted
Subtle surfaces, secondary chrome
--success
Completion, validation, positive state
--warning
Caution, unsaved changes, attention
--destructive
Delete, irreversible, errors
The status pairs above are the site’s own defaults, not tuned for this page. In the light theme the success and destructive pairs fall short of AA for small text — Contrast, below, measures the destructive one.
Every semantic role ships as a pair: --brand and --brand-foreground. If a designer uses one without the other, the audit catches it.
05 / Contrast
Three numbers decide who can read your product.
WCAG 2.1 gives three contrast thresholds. Body text needs 4.5:1. Large text (≥18pt or 14pt bold) can stop at 3:1. AAA at 7:1 is the recommended target for long-form reading. Anything below 3:1 is unreadable for most users with low vision — and brittle for everyone in glare.
Normal text targets AA (4.5:1). Large text (≥18pt or 14pt bold) can stop at 3:1. AAA is the recommended target for body text in long-form reading contexts.
Sample text · the brown fox.
Heading on background
Foreground · Background
Sample text · the brown fox.
Body on muted
Foreground · Muted
Sample text · the brown fox.
Caption on background
Muted-fg · Background
Sample text · the brown fox.
Action on brand
Brand-fg · Brand
Sample text · the brown fox.
Action on destructive
Dest-fg · Destructive
Sample text · the brown fox.
Placeholder ghost
Muted/60 · Background
In the light theme, the last card fails. Placeholder text at 60% opacity drops below 3:1 — visible to most, illegible to many. Either raise the opacity or move the hint outside the input. And the light destructive pair only clears AA Large — fine for a big bold label, not for body text. Switch the theme and every ratio above re-measures itself.
Unlike WCAG’s single ratio, APCA scores contrast against text size and weight — thin 14px text needs a higher Lc than bold 24px. Same pair, different verdict depending on how the ink is set.
06 / Dark mode
Not the inverse. a different shape of contrast.
Dark mode isn’t light mode with the lightness flipped. Emission reads differently from reflection — pure white text on pure black vibrates. Lift the surfaces toward charcoal, soften the ink toward ivory, and reduce brand saturation. The token names stay; the values rebalance.
Surface · default
Sample body text.
Surface · default
Sample body text.
Light mode uses pure white. Dark mode lifts off pure black (#000) by 10–15% to soften OLED bloom and reduce halation around small text.
Surface · raised
Sample body text.
Surface · raised
Sample body text.
Cards and overlays go a shade darker in light mode (recessed) but lighter in dark mode (raised). Elevation reverses direction.
Brand · primary
Sample body text.
Brand · primary
Sample body text.
Saturation gets pulled back in dark mode. A vibrant accent on a dark surface reads twice as loud as the same value on white — the eye dilates.
The semantic names stay identical across themes — only the primitive references swap. A button doesn’t know which mode it’s in.
07 / Anti-patterns
Four ways color systems fall apart.
Each one ships in week one, surfaces in month six, and takes a quarter to walk back.
Naming by appearance
Avoid
var(--blue-600)Prefer
var(--brand)Once the brand is no longer blue, every consumer needs a code change. Semantic tokens decouple meaning from value — primitives stay frozen, aliases evolve.
Inventing values inline
Avoid
background: #2e3447Prefer
background: var(--surface-2)One hand-typed hex becomes a hundred near-duplicates. Snap to the scale or extend it once — never both in the same week.
Color carrying all the meaning
Avoid
Red text means error · green means successPrefer
Icon + label + color · color is the third signal8% of men can't reliably distinguish red from green. Use color as reinforcement, not as the only signal — always pair with an icon or label.
Dark mode by inversion
Avoid
filter: invert(1) on the entire appPrefer
Paired dark tokens · different shape of contrastInversion breaks photos, charts, and brand color. Dark mode needs its own palette designed for emission rather than reflection — flatter shadows, softer brand, raised surfaces.
Always pair color with text or icon — never color alone.
Test in greyscale before you ship — does the hierarchy survive?
Audit at AA contrast minimum, target AAA for body text.
Color is one foundation. typography reads it.
Type is where color lands. A good color system without a paired type system gives you a screenshot, not an interface. Read the type scale, the line-height rules, and the measure that makes long-form readable.