Meeting WCAG colour contrast without guessing
For normal body text, WCAG level AA asks for a contrast ratio of at least 4.5:1 against its background. Large text needs 3:1. Interface components and meaningful graphics need 3:1. Level AAA raises text to 7:1, and large text to 4.5:1. Ratios run from 1:1 (identical colours) to 21:1 (pure black on pure white).
You cannot judge this by eye. Two pairs that look similarly readable on your monitor can sit on opposite sides of the threshold, so the ratio has to be calculated.
The thresholds
| What it is | AA | AAA |
|---|---|---|
| Normal text | 4.5:1 | 7:1 |
| Large text | 3:1 | 4.5:1 |
| Interface components and graphical objects | 3:1 | no higher bar |
These come from three success criteria: 1.4.3 Contrast (Minimum) at AA, 1.4.6 Contrast (Enhanced) at AAA, and 1.4.11 Non-text Contrast at AA, which covers the visual boundaries of controls and the parts of graphics you need in order to understand them.
Large text means at least 18 point, or 14 point if bold, which works out at roughly 24px regular or about 18.5px bold. It does not mean "a heading": a 20px heading is normal text as far as the rules are concerned and needs the full 4.5:1.
A few things are exempt: purely decorative text, text in an inactive control, text that is part of a picture containing other significant visual content, and logotypes. Placeholder and helper text are not exempt.
What the ratio actually measures
The formula compares the relative luminance of the two colours:
ratio = (L_lighter + 0.05) / (L_darker + 0.05)
Relative luminance is a number from 0 (black) to 1 (white) representing how much light a colour emits. It is calculated by undoing the sRGB gamma curve on each channel, then weighting them:
L = 0.2126 R + 0.7152 G + 0.0722 B (on linearised channels)
The 0.05 added to both sides models a little ambient light reflecting off the screen, which is why the maximum is 21 and not infinity.
Two things follow. The ratio depends only on luminance, not hue, so two colours can be wildly different in hue and still be nearly invisible against each other. And it is symmetric, so it does not matter which colour you call the foreground.
Relative luminance is not brightness
Look at those weights. Green contributes about 72% of luminance, red about 21%, and blue barely 7%. That is a model of human vision, not of how vivid a colour looks, and it produces results that feel wrong until you get used to them.
- Pure yellow
#FFFF00on white is about 1.07:1, almost invisible, despite yellow feeling like a strong colour. - The same yellow on black is about 19.6:1, nearly as high as white on black.
- Pure blue
#0000FFon black is about 2.4:1 and fails everything, though it looks bold and saturated.
This is also why HSL lightness is a poor proxy. Two colours can both sit at 50% lightness in HSL and have very different luminance, because HSL treats hue and lightness as independent and vision does not. Convert, then measure, rather than assuming a lightness step is a contrast step.
The mistakes that keep happening
Grey body text on white. This is the big one. The lightest grey that passes AA on white is about #767676, which comes to 4.54:1. One step lighter, #777777, is 4.48:1 and fails. Popular "muted" greys such as #999999 land near 2.9:1, less than two thirds of the requirement. If you want AAA on white, you need roughly #595959, which is exactly 7:1.
Placeholder and helper text. These get set several shades lighter than body copy because they are secondary. They are still text and still need 4.5:1.
Semi-transparent colours. A ratio taken from the declared colour is meaningless if the element sits at 60% opacity. Work out the composited colour that actually appears on screen, then measure that.
Text over images, gradients and video. The ratio changes from pixel to pixel and the worst pixel is the one that matters. The usual fix is a scrim behind the text rather than hoping the photo stays dark.
Brand colour buttons. White text on a mid-tone brand colour very often lands between 3:1 and 4.5:1, passing as a graphical object and failing as a label. Darken the button, or accept the failure knowingly.
Focus indicators and icon-only buttons. Both fall under the 3:1 non-text rule. A focus ring in a slightly different shade of the same colour is a common miss, as is a grey icon that is a button's only content.
Dark mode by inversion. Flipping a light palette does not preserve ratios, because luminance is not symmetric around the middle. Pure white on pure black gives the maximum 21:1, but many people find the halation uncomfortable, so an off-white on a very dark grey, such as #E6E6E6 on #121212 at about 15:1, is a common compromise.
Measuring from a screenshot. An eyedropper on the edge of anti-aliased text samples a blend of text and background, not the text colour. Sample the middle of a thick stroke, or better, read the values out of your CSS.
A workflow that avoids guessing
Fix the background first, then darken or lighten the foreground until it clears the threshold with a little room to spare, and record that pair. Build the palette out of approved pairs rather than approved colours: a colour is not accessible on its own, only in combination. Check every state too, not just the resting one, and both themes if you ship a dark mode.
One last note. A newer perceptual model, APCA, is being developed for a future version of WCAG and behaves quite differently, particularly for light text on dark backgrounds. It is worth knowing about, but the numbers to conform to today are the WCAG 2 ratios above.