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:
| Level | Text size | Required ratio |
| AA | Normal text | 4.5:1 |
| AA | Large text | 3:1 |
| AAA | Normal text | 7:1 |
| AAA | Large text | 4.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.