WCAG defines contrast precisely, and the definition is less intuitive than it looks. It is not the difference between two brightness values, and it is not a comparison of HSL lightness. Both of those produce numbers that look plausible and are wrong in ways that matter.
The actual formula
Each colour is converted to a relative luminance in three steps. First each channel is divided by 255. Then it is taken out of the sRGB gamma curve — displays encode values non-linearly, so the stored numbers are not proportional to emitted light. Then the three linear values are weighted and summed.
const channel = (c) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
const luminance = ({ r, g, b }) =>
0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
const ratio = (a, b) => {
const [hi, lo] = luminance(a) > luminance(b)
? [luminance(a), luminance(b)]
: [luminance(b), luminance(a)];
return (hi + 0.05) / (lo + 0.05);
};Two details carry most of the difference from a naive calculation. The gamma step, which averaging the raw channel values skips entirely. And the weights: green contributes 71% of perceived brightness and blue barely 7%. Pure blue on white is a far worse contrast than pure green on white, and a calculation that treats the channels equally cannot see that.
The + 0.05 on both sides models ambient light reflecting off the screen, which is why the maximum possible ratio is 21 rather than infinity.
Two fixed points to test against
The specification pins two values exactly, which makes any implementation easy to check:
- Black on white is exactly 21:1.
- Any colour against itself is exactly 1:1.
A checker that gets either of those wrong is not implementing WCAG. It is worth a thirty-second test before trusting a tool with a palette.
The thresholds
- 4.5:1 — AA, normal body text. The one that matters most often.
- 3:1 — AA for large text, meaning 18pt, or 14pt bold.
- 7:1 — AAA, normal text.
- 4.5:1 — AAA, large text.
- 3:1 — user interface components and meaningful graphics, under WCAG 2.1.
For reference, the lightest grey on white that passes AA for body text is around #767676, at 4.54:1. Anything lighter needs to be large or bold to qualify, and even then #999999 does not make it.
Things the ratio does not account for
Passing the threshold is a floor, not a guarantee of legibility. A few cases the number cannot capture:
- Transparency. A semi-transparent colour composites against whatever is behind it. Compute the ratio against the final rendered colour, not the declared one.
- Thin type. A 300-weight face at 4.5:1 is measurably harder to read than a 500-weight face at the same ratio.
- Text over images. The ratio changes across the image. A scrim or a solid backing plate is the reliable fix.
- Colour alone as meaning. Contrast says nothing about whether a red-versus-green distinction is perceivable to a colour-blind reader. That is a separate requirement.
A practical approach
Check the ratio at the point where you pick the colour, not at audit time. Retrofitting a palette is far more expensive than choosing a slightly darker grey at the start, because by then the colour is in a design system and every component inherits it.
Color ConverterShows the WCAG ratio against white and black for any colour, with the AA and AAA thresholds applied. Black on white returns exactly 21.