The math behind WCAG contrast checking
Why “4.5:1” is not a slider value, why averaging RGB does not work, and what the new APCA model fixes.
Every designer has hit the wall of a contrast checker telling them their pretty grey-on-white headline is not accessible. Most have nudged the color a notch darker until the tool went green and moved on. But the number that flipped — that “4.5:1” threshold — is the output of a fairly involved calculation rooted in mid-century color science. Knowing what it is actually measuring helps explain why some color pairs that pass feel worse than ones that fail, and why the next version of WCAG is throwing the whole formula out.
What “contrast ratio” actually measures
The WCAG 2 contrast ratio is a single number computed from two colors. Its formula is short:
ratio = (L_lighter + 0.05) / (L_darker + 0.05)The two L values are the relative luminance of each color — a number between 0 (pure black) and 1 (pure white) that captures how bright the colorlooks, not how bright it is in raw RGB. The ratio runs from 1:1 (the two colors are identical) to 21:1 (pure black on pure white). The +0.05 in both terms is a correction for ambient flare in real-world viewing conditions; if you removed it, the ratio for pure black against any other color would explode toward infinity, which would not match how the eye actually behaves.
Why 0.05 specifically?
Computing relative luminance is the hard part
Almost all of the interesting math is in computing each color's L. Two facts make it nontrivial.
First, sRGB is not linear. When a monitor receives the byte value 128 on its red channel, it does not emit half as many photons as it would for 255. Because human eyes have evolved to respond logarithmically to brightness, color encodings deliberately compress the bright end and stretch the dark end so that 256 levels per channel are enough to look smooth. Before doing any math, you have to linearize the channel value back to a real intensity.
The sRGB linearization curve is piecewise: a straight line for very dark values, and a power curve for everything else.
function linearize(channel: number): number {
// channel is 0..1 (i.e. byte / 255)
if (channel <= 0.03928) {
return channel / 12.92;
}
return Math.pow((channel + 0.055) / 1.055, 2.4);
}Second, the eye does not weigh the channels equally. A pure green pixel looks much brighter than a pure red pixel, which in turn looks brighter than a pure blue pixel. This is not an opinion — it falls out of how many of each cone type are in the retina and how the cones respond to wavelengths. The luminance formula encodes those weights:
function relativeLuminance(r: number, g: number, b: number): number {
const R = linearize(r / 255);
const G = linearize(g / 255);
const B = linearize(b / 255);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}Green is weighted ~10× more than blue. This is why a pure cyan background passes contrast checks against black far more easily than a pure magenta one of the same brightness, and why many designers' instinct that “just darken the blue” often does not move the ratio as much as expected.
Putting the two pieces together: full ratio computation for two colors is six channel linearizations, two weighted sums, and one division. The whole thing is maybe twenty lines of code. The reason contrast checking is not simpler — why you cannot just average the RGB and subtract — is entirely the linearization step and the unequal channel weights.
The thresholds and where they came from
Once you have the ratio, WCAG 2 buckets it:
- 4.5:1 — minimum for body text against its background. This is the AA target most teams aim for.
- 3:1 — minimum for large text (≥ 18pt regular or 14pt bold) and for graphical UI elements like icons or focus rings.
- 7:1— AAA target for body text. Roughly translates to “readable by users with 20/80 vision without assistive tech.”
Those three numbers are not arbitrary. They come from low-vision research conducted in the 1990s and 2000s that measured how much contrast users with various visual impairments needed to read comfortably at standard text sizes. The 4.5 threshold approximates the contrast a person with 20/40 vision (the legal driving cutoff in most US states) needs to read body text without strain. The 7 threshold matches roughly twice that. The 3 threshold is what most users can resolve a chunky shape against.
The thresholds are blunt, but they represent a real attempt to map a single number onto a population-level question: can most people, including people with reduced vision, read this?
Where this falls apart
WCAG 2 contrast was finalized in 2008. The way the formula compresses a complicated perceptual phenomenon into one number makes it easy to apply mechanically, but it produces a few notable failures.
Pure-yellow-on-black scores 19.5:1 but feels glaring. The math says it is one of the highest-contrast pairings possible. In practice it bleeds, vibrates, and is harder to read in long passages than dark grey on light grey at 7:1. The formula does not know about chromatic aberration or the discomfort of saturated complementary colors at the edges of letterforms.
Polarity is invisible. Black text on white and white text on black yield identical contrast ratios under WCAG 2. Empirically, dark-on-light text is more legible at small sizes for most people, and light-on-dark wins at very small sizes for some people with retinal conditions. The single-number formula cannot express any of this.
Thin fonts get a free pass. A delicate 12-pixel regular-weight font and a chunky 12-pixel black-weight font are held to the exact same threshold even though they read very differently against the same background.
What APCA fixes
WCAG 3 (still in working-draft as of this writing) introduces a replacement: APCA, the Accessible Perceptual Contrast Algorithm. Instead of a single ratio, APCA outputs an Lc value (Lightness Contrast) on a scale from −108 to +106, and pairs it with required values that depend on font size and weight.
The relevant differences:
- APCA distinguishes light text on dark from dark text on light. The signs of
Lctell you which way the contrast runs. - The required
Lcdrops as font weight increases — a heavy weight needs less contrast to remain legible. - The math is calibrated to a more recent perceptual model (CIECAM-derived) instead of the 2008 simplification.
- There is no
+0.05ambient-flare hack.
APCA produces results that match designer intuition more often. A common test case: very dark grey on near-black backgrounds, common in dark-mode UI, fails WCAG 2 catastrophically but passes APCA at most sizes — and does, in fact, read fine in practice.
WCAG 2 is still the standard you ship against
What Persimmon's contrast checker does
The contrast checker computes the WCAG 2 ratio using the formula above and shows the AA / AAA pass-fail bands for normal and large text. It also shows the underlying relative luminance for each color, so you can see whya pair barely passes or barely fails — usually it's a single channel doing all of the work.
The math is short, the implementation is short, and seeing the intermediate numbers makes it easier to fix a failing pair without the trial-and-error nudge dance. Drop two colors in and see what each one weighs.
Related tools
Keep reading
Why every PDF tool on Persimmon runs in your browser
When you upload a PDF to a stranger's server, you're handing them every word, signature, and number on the page. Here's why we refused to build it that way.
EngineeringChoosing v4 UUIDs vs ULIDs vs nanoid
Three identifier formats, three different bets on what matters. Pick the wrong one and the cost shows up months later as either ugly URLs or a slow database.