← Back to the blog
DesignApril 22, 2026·7 min read

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:

text
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?

The number comes from a 2008 paper that estimated how much stray light a typical CRT or LCD adds to the deepest black it can display. It is an approximation, baked into the standard, that nobody has revisited since. WCAG 3 drops it.

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.

typescript
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:

typescript
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 Lc tell you which way the contrast runs.
  • The required Lc drops 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.05 ambient-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

Until WCAG 3 is finalized and adopted into ADA / EN 301 549 / Section 508 references, the legal accessibility standard your site is judged against is still WCAG 2.x. The 4.5:1 number is the one auditors measure. Use APCA as a sanity check on the colors WCAG 2 flags as broken — but ship to WCAG 2.

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

Something wrong?