Skip to content
Visual craftLesson 4 of 6Beginner16 min

Color & contrast

Color carries mood and meaning, but contrast carries legibility. Learn the WCAG ratios, how to hit them, and why OKLCH makes accessible palettes easier.

Kept on this device only.

In one line: color is what a pairing feels like, but contrast is whether anyone can read it — and WCAG gives you exact ratios so legibility stops being a matter of taste.

What it is

A color has three properties worth separating: hue (which color it is — red, blue, teal), chroma (how saturated or vivid it is), and lightness (how close to black or white). Picking a color is mostly a hue-and-chroma decision: it sets mood, brand, and meaning.

Contrast is a different question entirely. It is the difference in lightness between two colors placed against each other — most often text on a background. Two colors can both be beautiful and still be unreadable together, because contrast is driven almost entirely by lightness, not by how nice the hues look side by side. The distinction matters: you choose color for feel, but you verify contrast for legibility, and the two decisions don't automatically agree.

Why it matters

Low contrast is the most common accessibility failure on the web, and it isn't an edge case. People with low vision, aging eyes, color-vision deficiencies, or a phone in direct sunlight all lose the faint-gray-on-white text that looks fine on a designer's calibrated monitor in a dark room.

It is also a baseline, not a nicety. WCAG's contrast rules are referenced by accessibility legislation in many jurisdictions, so "the body text is a bit light" can be an actual compliance gap, not just an aesthetic note. Hitting the ratios is the cheapest accessibility win there is — it costs a lightness tweak, and it makes the product more usable for everyone, not only people who depend on it.

See it

Live demo
Tweak it2
Pair a foreground and background; the demo computes the WCAG ratio and shows what passes.

Pick a foreground and a background from the two selects and watch the live ratio and the pass/fail rows update. Notice that the swatch shows the real pairing — so you can see that muted gray on surface looks fine but quietly misses the 4.5:1 bar for normal text, while ink on surface clears even the strict 7:1 AAA threshold.

How it works

Contrast ratio is computed from relative luminance — a single number for how much light a color emits, weighting green most heavily because the eye is most sensitive to it. Each color's sRGB channels are normalized to 0–1, linearized (undoing the sRGB gamma curve), then combined as L = 0.2126·R + 0.7152·G + 0.0722·B.

The ratio between two colors is then:

(L_lighter + 0.05) / (L_darker + 0.05)

The + 0.05 models ambient flare so the formula behaves at the extremes; the result runs from 1:1 (identical) to 21:1 (pure black on pure white). The thresholds you aim for:

  • 4.5:1 — normal body text, WCAG AA (1.4.3).
  • 3:1 — large text (roughly 24px, or 18.66px bold), WCAG AA.
  • 7:1 — normal text, the stricter AAA level.
  • 3:1 — non-text UI: control borders, focus rings, icons, chart strokes (WCAG 1.4.11). This is the one people forget — a 1px hairline border can fail even when the text passes.

Build it

Define color as semantic roles — text, surface, action — in OKLCH, and treat the L channel as the dial you turn to hit a target ratio. Same hue, different lightness, predictable contrast.

CSS
CSS — an OKLCH semantic palette tuned for contrast
:root {
  /* oklch(lightness chroma hue) — L is the contrast dial. */
 
  /* Surfaces: very light, near-zero chroma. */
  --surface: oklch(99% 0 0); /* page background */
  --surface-muted: oklch(96.5% 0.004 250); /* cards, wells */
 
  /* Text: drop L far below the surface to win the ratio. */
  --text: oklch(22% 0.02 265); /* ~13:1 on --surface (AAA) */
  --text-muted: oklch(46% 0.02 265); /* lower it only to ~4.6:1 — still AA */
 
  /* Action: a brand hue that must also pass on its own fill. */
  --action: oklch(55% 0.18 255); /* button background */
  --action-text: oklch(99% 0 0); /* white label, ~4.6:1 on --action */
}
 
.button {
  background: var(--action);
  color: var(--action-text);
  border: 1px solid var(--action); /* UI element: needs 3:1 vs surface */
}
 
/* To hit a stricter target, you almost never change hue or chroma —
   you nudge the L channel down (darker text) or up (lighter surface)
   until the contrast checker clears the bar. */

Make it yours

Use the controls beside the demo above to change foreground and background — each change updates the example live.

Experiment in the playground
  • Hold the hue and chroma of a token fixed and lower only its OKLCH lightness until it crosses 4.5:1 — that's the whole technique for "make this text accessible."
  • Check your accent color as a fill, not just as text: a button's label must clear 4.5:1 against the button's own background, which often forces a darker accent than the brand swatch.
  • Re-run every pairing in dark mode. Inverting the palette flips which side is lighter, and a combo that passes on white can quietly fail on a dark surface.

Reproduce it with an LLM

Reproduce it with an LLM

You are a design-systems engineer. For a fictional brand called Lumen, define a small semantic color set — text, muted text, surface, surface-muted, action, action-text, and a danger color — as CSS custom properties using OKLCH. Then produce a contrast table: for every foreground/background pairing a designer is likely to use (text on surface, action-text on action, muted on surface, etc.), give the computed WCAG contrast ratio and whether it passes AA for normal text (4.5:1), AA for large text (3:1), and AAA (7:1). Fix any pairing that fails by adjusting the OKLCH lightness, and show the corrected value. Return the tokens, the table, and a one-line note on each fix.

Pitfalls & accessibility

  • Don't trust your eyes on near-misses. A pairing that "looks fine" at 4.2:1 still fails AA; run an actual checker rather than eyeballing it, especially for muted secondary text where designers tend to push the lightness too far.

Further reading