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 L keeps 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 rolebg, 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.