On this page
Why one hex is never enough
Interfaces don't consume colors; they consume roles — page background, card surface, border, muted text, body text, primary action, its hover, its focus ring. Ship a product on one brand hex and within a week you'll find fifteen ad-hoc variants of it smeared through the codebase, each eyeballed by a different person. A color system is the antidote: a small set of deliberate decisions, named, that every screen draws from.
Layer one: primitive scales
The foundation is the numbered ladder — blue-50 through blue-950 style scales like the ones in our Tailwind color chart: each family a fixed hue walked through lightness steps. You need surprisingly few: your brand hue's scale (generate the 50–900 ladder from one base in the palette generator), one disciplined neutral scale, and scales for the semantic hues (success, warning, error, info). The craft points: steps should look evenly spaced — which arithmetic RGB steps don't produce, so generate in a perceptual space like OKLCH or borrow a professionally tuned scale — and every family should place comparable lightness at comparable step numbers, so "swap blue-600 for red-600" behaves predictably.
Layer two: semantic tokens
Primitives answer "what colors exist"; tokens answer "what is each for." The token layer maps roles to primitives: surface → white, border → neutral-200, text-muted → neutral-500, primary → blue-600, primary-hover → blue-700. Components reference only tokens, never primitives. This single discipline buys you theming (tokens re-map per theme), sane refactors (change primary once), and — critically — a place where accessibility rules can be enforced by construction rather than audited after the fact.
Anchor the scale to contrast, not taste
The most valuable trick in scale design: decide which step numbers carry which guarantees, then tune the scale until they're true. For a light theme, a workable set of anchors:
- 500/600 — the brand workhorse: must clear 4.5:1 on white so it can serve as link text and button fills with white labels get checked separately.
- 600/700 — text-safe on tinted
50backgrounds, not just pure white. - 200/300 — borders and dividers: aim for the 3:1 non-text minimum against the surface they sit on.
- 50/100 — tinted backgrounds that must keep
900-level text at AAA-ish comfort.
Verify each anchor pair in the contrast checker — the WCAG guide explains the thresholds — and record the guarantees next to the tokens ("primary always passes AA on surface"). Now developers don't need to re-check contrast per feature; the system made the promise once.
Dark mode is a re-mapping, not a mirror
With tokens in place, dark mode becomes a second mapping table instead of a redesign: surface → neutral-900, border → neutral-700, primary → blue-400 — lighter and less saturated than the light theme's 600, for the halation and vibration reasons covered in the dark mode guide. Note it is not a numeric mirror (600 does not become 300 across the board); each role gets re-decided against its new dark surface, and each anchor pair gets re-verified. The primitives don't change at all — that's the payoff of the two-layer structure.
Guardrails that keep it healthy
Run the full palette through a pairwise color blindness check so state colors stay distinguishable where they meet. Export the system as CSS custom properties from day one (every palette page on this site emits ready-to-paste variables). Resist scale sprawl — a new primitive family needs a reason, because every family multiplies the pairs someone must keep accessible. And when a designer asks for "a slightly different blue just this once," the system's job is to make the right answer the easy answer: the nearest existing step is one token away, and the bespoke hex has to justify itself. That friction — small, constant, in the right direction — is what a color system actually is.