WCAG contrast ratios: the math behind the numbers
What a 4.5:1 ratio actually measures: relative luminance, the WCAG formula, and why large text gets 3:1. Understand the numbers instead of memorising them.
A ratio is a quotient of two luminances
Every WCAG 2.x contrast figure is computed in three steps. First, convert the foreground and background colors to relative luminance — a number from 0 (black) to 1 (white) that weights how bright the channels look to human eyes. Then add the same constant, 0.05, to both values. Then divide the lighter value by the darker one.
So 4.5:1 does not mean 'four and a half of something you can count'; it means the lighter color is 4.5 times as luminous, after a small tone-mapped floor. The floor is why pure black on pure white returns 21:1 — (1.0 + 0.05) ÷ (0.0 + 0.05) — and why two very dark colors can never score highly even if they feel distinct.
function contrastRatio(hexFg, hexBg) {
const L = (hex) => {
const [r, g, b] = [0, 2, 4]
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const [l1, l2] = [L(hexFg), L(hexBg)].sort((a, b) => b - a);
return (l1 + 0.05) / (l2 + 0.05);
}The four thresholds, and why large text is easier
WCAG defines two pass levels — AA and AAA — each with a normal-text and large-text bar. The numbers exist to be remembered, but the reason for 3:1 on large text is real: at 18.66px bold or 24px regular and up, glyph shapes fill more pixels, so the same luminance difference is easier to resolve. Small text at 4.5:1 buys the contrast that keeps dense paragraphs readable.
The thresholds in practice: AA normal ≥ 4.5:1, AA large ≥ 3:1, AAA normal ≥ 7:1, AAA large ≥ 4.5:1. Notice AAA large reuses AA normal's number — the two scales are deliberately nested.
- AA normal text ≥ 4.5:1 — the bar your body copy must clear
- AA large text ≥ 3:1 — buttons, headings, icons
- AAA normal text ≥ 7:1 — the ceiling most designers aim for on primary text
- AAA large text ≥ 4.5:1 — nested reuse of the small-text minimum
Tip
'Large text' means at least 24px regular, or at least 18.66px bold (19px). The WCAG definition uses the type size in CSS pixels, so it stays consistent across devices.
Contrast is lightness, not hue
Relative luminance has no hue information. Two colors can differ wildly in hue yet score a low ratio, which is why red-on-green pairing fails WCAG even though it 'feels' high contrast to a trichromat. Equally, desaturating a compliant pair — same lightness, less chroma — usually keeps it compliant, because luminance only barely depends on saturation in OKLCH.
That asymmetry is also the algorithm's blind spot that a human review must catch: a ratio passes, but people with some color-vision deficiencies may still struggle to tell two hues apart when the ratio is carried mostly by hue, not lightness. The fix is never 'increase the ratio' until it stops looking good; the fix is to add a second cue, like a label, icon or pattern.
What the numbers can't tell you
A checker reports a single ratio and pass/fail state for one pair. A real interface is a matrix — every text token against every background it can sit on, in light and dark. Grading a whole palette pair-by-pair by hand is where errors hide: the body text passes on the app background and fails on one card surface.
That is what the palette checker exists for: feed it the palette, and it grades every foreground/background combination against AA and stacks the results, so an accidental failure on one surface surfaces as a matrix cell instead of a review note. Pass the matrix, and the individual numbers stop being the job.
Related free tools
Want to apply this right now? Open the color palette generator and try it with your own brand color — free, no sign-up.