WCAG Contrast Explained (With the Actual Formula)
The relative luminance and contrast-ratio math behind every AA/AAA pass-fail badge, worked through with real numbers instead of taken on faith.
"4.5:1" gets repeated constantly in accessibility checklists, and most people who cite it correctly have never actually seen the formula that produces it. That's fine for day-to-day work — you can lean on the contrast checker and move on — but understanding the math changes how you read edge cases, and there are more edge cases than the one-line summary suggests.
Step one: relative luminance, not RGB brightness
The naive approach to "how bright is this color" is averaging the RGB channels or taking their max. WCAG doesn't do that, because it doesn't match how the eye actually weights color. Instead it defines relative luminance using coefficients derived from the CIE 1931 photopic sensitivity curve — the human eye is far more sensitive to green light than to blue:
function channelToLinear(c8) {
const c = c8 / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function relativeLuminance({ r, g, b }) {
const R = channelToLinear(r);
const G = channelToLinear(g);
const B = channelToLinear(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B; // green weighted ~3.4x blue
}Two things matter in that snippet that are easy to miss. First, each channel is linearized before the weighted sum — sRGB values are gamma-encoded (roughly a 2.4-power curve) precisely because human perception of brightness is itself nonlinear, and you have to undo that encoding before doing luminance math or the result is wrong. Second, the weights (0.2126 red, 0.7152 green, 0.0722 blue) sum to 1.0 and reflect that green dominates perceived brightness by a wide margin — a pure green at full channel value reads as far brighter than a pure blue at the same channel value, even though both are "255" in RGB terms.
Step two: contrast ratio from two luminances
Once you have relative luminance (a number from 0, pure black, to 1, pure white) for both the foreground and background color, the ratio is:
function contrastRatio(rgbA, rgbB) {
const lA = relativeLuminance(rgbA);
const lB = relativeLuminance(rgbB);
const lighter = Math.max(lA, lB);
const darker = Math.min(lA, lB);
return (lighter + 0.05) / (darker + 0.05);
}The +0.05 on both sides of the ratio is a deliberate offset accounting for ambient light scatter — pure black on a real screen in a real room never reads as a true luminance of zero, so the formula avoids a division that would otherwise blow up toward infinity for black-on-white and instead caps the theoretical maximum at exactly 21:1 (white against black). Pure black on pure white: (1 + 0.05) / (0 + 0.05) = 21. That 21:1 ceiling is why you'll never see a contrast checker report anything higher.
Step three: the actual pass thresholds, and why there are four of them
WCAG 2.x sets different thresholds depending on text size and content type, because legibility research shows larger text tolerates lower contrast without becoming harder to read:
- Normal text, AA: 4.5:1 minimum.
- Normal text, AAA: 7:1 minimum.
- Large text (≥18pt, or ≥14pt bold), AA: 3:1 minimum.
- Large text, AAA: 4.5:1 minimum.
- UI components and graphical objects, AA: 3:1 minimum (this covers things like button borders, form input outlines, and icon-only controls — added in WCAG 2.1's 1.4.11 Non-text Contrast criterion, not the original 2.0 text rule).
Worked example: white text (#FFFFFF, luminance 1.0) on this site's teal accent color, hex #008080 (a standard CSS named color, reference page here). Teal's relative luminance works out to roughly 0.117 once you run its RGB through the linearization step. Ratio: (1.0 + 0.05) / (0.117 + 0.05) ≈ 6.29. That clears 4.5:1 (AA normal text) comfortably but falls short of 7:1 (AAA normal text) — so it's a legitimate, spec-compliant choice for body copy at the AA bar, but an accessibility audit targeting AAA would flag it. Try it yourself, along with any other pairing, in the contrast checker — it implements this exact formula, not an approximation.
The gotchas that the one-line summary hides
Contrast is symmetric but context isn't. The formula doesn't care which color is "foreground" — swapping foreground and background gives the identical ratio. What it doesn't account for is real-world context: text over a busy photo, text with a semi-transparent overlay, or text rendered with anti-aliasing at very small sizes can all be harder to read than the raw ratio implies. The number is a floor, not a guarantee of actual legibility.
Alpha transparency isn't handled by this formula at all. If your foreground color has an alpha channel (say, white text at 80% opacity over a colored background), you need to flatten it to an effective solid color against that specific background first — composite the alpha, then run the contrast formula on the composited RGB. Checking the nominal foreground color's contrast against the background while ignoring its own transparency will overstate the actual contrast.
"Large text" is measured in points, not pixels, and the threshold is oddly specific. 18pt is roughly 24px at standard 96dpi rendering; 14pt bold is roughly 18.66px bold. If your design system uses pixel-based type scales, translate carefully — a 20px regular-weight label does not qualify as "large text" even though it looks sizeable, because it's under the 24px-equivalent AA threshold for large-text treatment.
Non-text contrast is frequently skipped entirely. Teams routinely audit body text contrast and stop there, leaving disabled-looking form borders, low-contrast icon buttons, and faint focus rings unaudited — all of which fall under the 3:1 non-text criterion just as much as text does.
Where this fits into building a whole palette
Contrast checking one pair at a time is necessary but not sufficient for a real palette — you need every text/background combination your design system actually uses to clear its threshold, plus a plan for color-blind users who may not distinguish hue differences at all regardless of luminance contrast. The full workflow, including how to structure a palette so contrast checking doesn't become a late-stage fire drill, is in choosing an accessible color palette, and the color-vision-deficiency side of accessibility (a separate concern from luminance contrast) is covered in designing for color blindness.