Contrast & Accessibility
WCAG 2.x contrast ratios, APCA, and how to pick readable color pairs.
Contrast is where color stops being aesthetic and becomes functional: it decides whether people can read your interface at all. Two systems matter today - the WCAG 2.x contrast ratio that regulations reference, and APCA, the perceptually accurate model built for WCAG's successor.
Why contrast is the first check#
Reading depends almost entirely on luminance contrast- the light/dark difference between text and background. Hue contrast barely helps: red on green with equal luminance is nearly unreadable despite being “opposite” colors. Low vision, aging eyes (contrast sensitivity roughly halves by age 65), bright sunlight, and cheap panels all shrink the readable range further.
WCAG 2.x contrast ratios#
WCAG 2.x compares the relative luminance of the two colors: (L1 + 0.05) / (L2 + 0.05), giving a ratio from 1:1 (identical) to 21:1 (black on white). The thresholds:
| Level | Normal text | Large text (≥24px / ≥18.7px bold) | UI components |
|---|---|---|---|
| AA (required) | 4.5:1 | 3:1 | 3:1 |
| AAA (enhanced) | 7:1 | 4.5:1 | - |
AA is the level laws and procurement standards typically cite (ADA settlements, the EU Web Accessibility Directive, Section 508). Treat 4.5:1 as a floor for body text, not a target.
Where the ratio falls short#
- It ignores polarity - light-on-dark and dark-on-light score identically, but dark mode needs more contrast to read equally well (glare and halation around light text).
- It misjudges the mid-range - some passing 4.5:1 pairs are genuinely hard to read, while some failing pairs (especially dark-mode oranges) read fine. The formula predates modern research on supra-threshold contrast.
- It knows nothing about fonts - a 4.5:1 hairline thin font is far less readable than 4.5:1 at a sturdy weight.
APCA: contrast, modernized#
The Accessible Perceptual Contrast Algorithm (APCA), developed for the draft WCAG 3, models perceived lightness contrast directly. It outputs Lc (lightness contrast) from ~0 to ~106, is polarity-aware (dark mode scores differently), and is meant to be read against font size and weight:
| Lc score | Rough use |
|---|---|
| Lc 90+ | Body text at small sizes; the AAA-like tier |
| Lc 75 | Minimum for body text |
| Lc 60 | Minimum for large/bold text (≈ AA 4.5:1 in spirit) |
| Lc 45 | Headlines, large UI text (≈ AA 3:1 in spirit) |
| Lc 30 | Non-text UI elements, placeholders |
| Lc 15 | Absolute floor for any visible element |
In practice#
- Decide text color per shade, systematically. In OKLCH, backgrounds with L ≳ 0.65–0.7 usually want dark text and below that white text - one threshold, applied everywhere.
- Never encode meaning in color alone - pair state colors with icons or labels (this overlaps with the Color Blindness chapter).
- Check disabled and placeholder states - they are the most common real-world failures, sitting in the 2–3:1 zone.
- Test dark mode separately - inverting a palette does not preserve its contrast, and APCA will score it differently.
:root {
--surface: oklch(0.97 0.01 250);
--on-surface: oklch(0.25 0.02 250); /* 12.6:1 on --surface */
--primary: oklch(0.55 0.19 250);
--on-primary: oklch(0.99 0 0); /* 5.4:1 on --primary */
}