WCAG Contrast: How to Calculate and Fix It
Published 2026-10-31
Contrast ratio answers one question: can a human read this text against this background?
It is a number between 1:1 (identical colours) and 21:1 (black on white). The thresholds:
| Level | Normal text | Large text | UI components |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | 3:1 |
| AAA | 7:1 | 4.5:1 | — |
"Large text" means 18pt (24px) regular, or 14pt (18.66px) bold. Below that, the 4.5:1 rule applies.
The formula, because it explains the failures
L = 0.2126 R + 0.7152 G + 0.0722 B (R, G, B linearised, 0..1)
ratio = (L1 + 0.05) / (L2 + 0.05) (L1 = lighter)
Two things follow directly:
1. Green dominates luminance. The coefficients are 0.21 / 0.72 / 0.07. Green contributes ten times more than blue. A saturated blue and a saturated yellow at the same HSL lightness are nowhere near the same perceptual brightness.
2. Linearisation matters. sRGB values are gamma-encoded. Before the weighted sum, each channel must be converted:
c = c8 / 255
c_lin = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4
Skipping this step is the most common implementation bug in homemade contrast checkers — it produces ratios that are wrong in both directions, and inconsistently so.
Why "darken it a bit" backfires
If your text is light grey on a white card and fails, darkening helps. But if the card is dark, darkening the text lowers the ratio — you are moving both colours in the same direction.
The correct move depends on which side is the background:
- Light background → make text darker, or background lighter
- Dark background → make text lighter, or background darker
This is why a single colour token cannot serve both themes. The hover state that works on white (L 51% → 45%) disappears on #111. Define separate tokens per theme rather than computing one from the other.
Three fixes that keep the palette intact
1. Change lightness, not hue
Adjusting HSL lightness keeps the brand hue and saturation. Moving L by 5–10 points usually clears 4.5:1 without changing the colour's identity.
--text-muted: hsl(215, 16%, 46%); /* 4.5:1 on white */
--text-muted: hsl(215, 16%, 40%); /* 6.2:1 on white */
2. Do not use pure grey
A slightly tinted grey reads as intentional and gives you tuning room. Pure #888 is the hardest value to move — a few points darker and it looks black, a few points lighter and it disappears.
3. Reserve low contrast for decoration
Dividers, disabled states, placeholder text — these can sit below 4.5:1 because they are not content. Body text, labels, and anything a user must read cannot.
Careful with placeholders: they are often the only hint about the expected format, so treating them as decoration is a real accessibility problem. 3:1 is the pragmatic floor.
The failures that show up most
| Symptom | Cause | Fix |
|---|---|---|
| Grey-on-white body text at 3.8:1 | #767676-adjacent greys |
Darken to #595959 |
| White text on a brand colour | Brand yellow/green is too light | Use dark text on light brands |
| Link indistinguishable from text | Same colour, only underlined on hover | Add permanent underline or increase weight |
| Text on a hero image | Variable background | Overlay a scrim, or pick the worst-case region |
| Disabled button unreadable | Below 3:1 | 3:1 minimum; "disabled" does not mean invisible |
That last one is worth repeating: WCAG does exempt disabled controls, but users still need to know a control exists.
Checking your palette
Colour Converter converts between HEX, RGB, HSL and CMYK — the fastest way to move a colour in HSL and read the resulting hex. For a full audit, run your tokens through a contrast checker pair by pair; do not eyeball it, because the luminance weighting makes intuition unreliable.
Also check the CMYK preview before print: some accessible colours sit outside the printable gamut, and the printed result can be flatter than the screen, which changes the effective contrast.
Summary
- Contrast is weighted luminance, not "how different the colours look".
- Green contributes 0.72 of the luminance — that is why blue-on-yellow and yellow-on-white behave nothing alike.
- Move lightness, keep hue.
- One token per theme; never derive a dark-mode colour by arithmetic from the light one.
- 4.5:1 for body text, 3:1 for large text and UI, and do not exempt everything you find inconvenient.