Color theory for front-end developers
April 30, 2026 · 2 min read
Most of us pick colors the way we learned to: open a color picker, nudge until it looks right, paste the hex. It works until you need a system — a palette that stays balanced across light and dark, hits contrast targets, and generates consistent shades. That's where a little color theory earns its keep.
Hex and HSL lie about brightness
The hex and HSL models are about how a screen mixes light, not how an eye
perceives it. The classic tell: take HSL colors at the same lightness and
they look nothing alike in brightness.
/* same L in HSL — wildly different perceived brightness */
--yellow: hsl(60 100% 50%); /* looks almost white */
--blue: hsl(240 100% 50%); /* looks nearly black */So "lightness" in HSL doesn't mean what you'd hope. Build a ramp by stepping
L evenly and the steps come out uneven to the eye — bunched up in some ranges,
stretched in others.
OKLCH thinks in perception
OKLCH describes color the way you actually see it: L for perceptual
lightness, C for chroma (how saturated), H for hue. Equal steps in L
look like equal steps in brightness, which is exactly what you want for a scale.
--surface: oklch(0.2 0 0); /* near-black, no chroma */
--text: oklch(0.98 0 0); /* near-white */
--accent: oklch(0.7 0.18 250); /* a blue at a controlled lightness */Two properties make it the right default for a design system:
- Predictable ramps. Hold hue and chroma, step lightness, and you get a tonal scale that reads as evenly spaced.
- Honest hue. Shifting only
Lkeeps the hue stable — no surprise drift toward a different color as a shade gets darker.
Contrast is a ratio, not a vibe
Readability isn't about how "dark" text feels — it's the contrast ratio between foreground and background luminance. WCAG sets the bar at 4.5:1 for body text and 3:1 for large text. The convenient part of a perceptual model is that the lightness channel tracks closely with what drives that ratio, so you can reason about contrast while you design instead of auditing after.
Design the lightness relationships first and the contrast tends to fall into
place. Pick colors by hue and saturation first, and you'll spend the rest of
the project fighting the contrast checker.
Build the palette as tokens, not values
The last step is to stop scattering raw colors through the codebase. Define a
small ramp once, then name colors by role — bg, surface, border,
text — so components reference meaning, not a specific shade.
:root {
--black-200: oklch(0.18 0 0);
--black-300: oklch(0.2 0 0);
--white-200: oklch(0.99 0 0);
/* semantic layer — components only ever see these */
--bg-primary: var(--black-300);
--text-primary: var(--white-200);
--border-primary: var(--black-200);
}Now a redesign is a change to the ramp, not a find-and-replace across a hundred files — and dark mode is a second set of role assignments, not a second codebase. The whole site stays in tune because every color traces back to the same small set of decisions.