You picked a nice blue for your buttons. Then you needed a lighter one for hover, a darker one for the pressed state, and a faint one for a background tint. Now you have nine blues, all slightly wrong. The fix is to stop choosing colors and start building ramps.
Build ramps, not colors
A ramp is one hue at eleven fixed lightness steps: 50, 100, 200, all the way up to 900 and 950. 50 is your faintest tint; 950 is near-black. You generate it once — Radix Colors or an OKLCH generator does the math — and every shade you’ll ever need already exists and stays in the same family. Ten or eleven steps is the sweet spot: enough range for hover, borders, and disabled states without a pile of near-duplicates.
Alias by meaning
Components should never reference blue-600 directly. Add a layer of semantic tokens on top and point each one at a step in a ramp. Rebranding then means repointing a handful of aliases, not find-and-replacing hex across the whole app. A component then says bg-background or text-foreground and never learns which hue sits underneath.
:root {
--primary: var(--blue-600);
--primary-hover: var(--blue-700);
--background: var(--gray-50);
--foreground: var(--gray-950);
--border: var(--gray-200);
}Meet the contrast floor
Color isn’t done until it’s readable. Body text needs a 4.5:1 contrast ratio against its background; large text and UI can drop to 3:1. This is where fixed steps pay off — test each pair once and you know gray-600 on gray-50always passes. Never ship a gray you haven’t checked. Run each pair through a contrast checker once — Chrome DevTools has one built in — and you never have to guess again.
Dark isn’t light reversed
The rookie move is flipping the ramp — 50 becomes 950 — and shipping it. It looks wrong because dark UIs need less contrast, not inverted contrast. Build a separate dark ramp: near-black surfaces around gray-950, off-white text near gray-100, and brand hues nudged brighter and less saturated so they don’t glow. Same aliases, second set of values.
Ship it
Generate two ramps, alias five semantic tokens, check every pair against 4.5:1, and you have a color system a designer would sign off on. Component libraries like Spectrum UI ship these tokens by default, so you can borrow the structure instead of hand-rolling it. Either way: ramps first, aliases second, raw hex never.