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

  1. Contrast is weighted luminance, not "how different the colours look".
  2. Green contributes 0.72 of the luminance — that is why blue-on-yellow and yellow-on-white behave nothing alike.
  3. Move lightness, keep hue.
  4. One token per theme; never derive a dark-mode colour by arithmetic from the light one.
  5. 4.5:1 for body text, 3:1 for large text and UI, and do not exempt everything you find inconvenient.