WCAG Contrast Checker: The Exact Formula, Four Thresholds, and a Worked Example

A designer hands you a palette, and two of the text pairs are unreadable for a

meaningful share of users. Arguing about it is slower than computing it. This guide

explains the WCAG contrast checker we ship at the source-code level, so you know

what the number on screen means, where it comes from, and where it stops telling

the truth.

The four thresholds the checker enforces

The tool evaluates two levels with four comparisons, applied in this order:

LevelText sizeRequired ratio
AANormal text4.5:1
AALarge text3:1
AAANormal text7:1
AAALarge text4.5:1

Each row passes or fails independently. A pair at 5:1 passes both AA rows and fails

both AAA rows. The WCAG standard defines large text as 18pt, about 24px, or 14pt

bold, about 19px, and that boundary is a specification detail you apply when

deciding which row governs your element.

The luminance formula in the code

Every ratio starts with relative luminance. The implementation takes each channel,

divides by 255, and applies one of two transforms:


c = channel / 255
if c <= 0.03928:
    linear = c / 12.92
else:
    linear = ((c + 0.055) / 1.055) ** 2.4

L = 0.2126 * R + 0.7152 * G + 0.0722 * B

The three weights encode how the eye responds to wavelengths. Green dominates at

0.7152, red follows at 0.2126, blue trails at 0.0722. This is why darkening the

green channel changes luminance more than darkening blue by the same amount.

The contrast ratio itself is one line:


ratio = (lighter + 0.05) / (darker + 0.05)

The code takes the lighter and darker of the two luminances itself, so foreground

and background can be entered in either order. Pure black on pure white gives

1.05 divided by 0.05, the maximum of 21:1. Two identical colors give 1:1, the

minimum. No real pair can leave that range.

A worked example from the exact code: #999999 on white

Light gray on white is the most common contrast failure on the web. Here is the

computation the tool performs, step by step:

1. All three channels of #999999 are 153, and 153 divided by 255 is 0.6.

2. 0.6 is above 0.03928, so the gamma branch applies.

3. ((0.6 + 0.055) / 1.055) raised to the power 2.4 equals 0.3185.

4. Luminance is 0.2126 plus 0.7152 plus 0.0722, which sums to 1, multiplied by

0.3185, giving 0.3185.

5. White has luminance 1, so the ratio is 1.05 divided by 0.3685.

6. The result is 2.85:1, and the tool displays 2.85:1.

The verdict screen shows every badge red. 2.85 fails AA normal text at 4.5, fails

AA large text at 3, and fails both AAA rows. Light gray body text on white is not a

style choice, it is four failing grades.

For contrast, the tool's own default pair shows what passing looks like. Slate

#1e293b on white computes to 14.63:1, which clears every threshold including AAA

normal text at 7:1.

Input rules the checker enforces

The tool accepts one format: six-digit hex. The validation pattern requires a hash

followed by exactly six hexadecimal digits. Three-digit shorthand like #999 is

rejected.

The failure behavior is worth knowing. If either field holds an invalid value, the

computed ratio returns zero. The display reads 0.00:1 and all four rows show fail.

If you see a flat zero, your input format is wrong, your colors are not actually

failing that catastrophically.

The swap button exchanges foreground and background. The ratio does not change,

because the formula orders the luminances itself. Use the swap to preview text and

background in the arrangement your interface actually uses.

Where a contrast checker lies to you

A pair-of-hexes tool has a blind spot the size of modern CSS. These limits are real

and they matter more than the math:

  • Transparency is invisible to it. You type flat hex values, so rgba text over an

image or a gradient gets measured against a background that does not exist.

Flatten the composite color yourself first, then check that hex.

  • Gradients have no single background. A card over a gradient must pass at the

worst point, the region closest in luminance to the text color. Test that point.

  • One ratio per pair means one element per test. A page passes when every text

pair passes, including hover states, placeholders, and disabled controls.

Here is a concrete number from our own stack. The glassmorphism generator ships a

preset gradient starting with #f093fb. White text on that color computes to 2.04:1

under this exact formula, a failure of every threshold. Frosted glass aesthetics

and readable text collide exactly there, and only measuring the composed colors

catches it.

Audit a palette, step by step

1. List every text color and the background it sits on, including hover and dark

mode variants.

2. Convert any translucent color to its effective flat hex before entering it.

3. Enter each pair as six-digit hex, foreground first.

4. Read the four badges, and record the ratio, not just pass or fail.

5. Apply this rule: if the ratio is between 4.4 and 4.6, treat it as a failure and

re-tint, because rounding and font rendering sit on the edge.

6. Re-run the audit whenever the palette changes, not once at launch.

Checklist for an accessibility sign-off

  • Every text and background pair entered as six-digit hex.
  • Translucent colors flattened to effective hex before testing.
  • Large text exemptions claimed only for genuinely large text.
  • Dark mode palette tested as its own set of pairs.
  • Borderline ratios re-tinted rather than waved through.
  • Worst-case gradient regions tested, not the average.

Contrast is the rare accessibility fix that is fully computable before shipping. The

formula fits in one line, the thresholds are four numbers, and the failure modes

come from inputs the formula never sees, transparency and gradients, rather than

from the math.

What are the failing pairs in your current palette, and did any of them sit just

under a threshold? Run them through our

[contrast checker](https://webrecast.com/en/contrast-checker) and see the four

verdicts for yourself.