WCAG color contrast checker — and why your result is often wrong
The formula is the easy part. Most wrong contrast answers come from measuring against the wrong background.
Check a pair of colors
7.00:1
Accepts #rgb, #rrggbb and rgb(…).
Colors are treated as opaque — if yours has transparency, read
the first trap below before trusting the number.
What WCAG requires
| Criterion | Level | Normal text | Large text |
|---|---|---|---|
| 1.4.3 Contrast (Minimum) | AA | 4.5:1 | 3:1 |
| 1.4.6 Contrast (Enhanced) | AAA | 7:1 | 4.5:1 |
| 1.4.11 Non-text Contrast | AA | 3:1 for UI components and meaningful graphics | |
Large text means at least 18 point (24 CSS pixels), or at least 14 point bold (about 18.66 CSS pixels at weight 700). Below that, it is normal text and needs 4.5:1 at AA.
The ratio itself comes from relative luminance:
(L1 + 0.05) / (L2 + 0.05), lighter over darker. It runs
from 1:1 (identical) to 21:1 (black on white).
Four ways contrast gets measured wrongly
We learned each of these by getting it wrong in our own checker and noticing only because it flagged sites whose accessibility we had every reason to trust.
1. Translucent backgrounds, read one layer deep
getComputedStyle(el).backgroundColor on most text elements
returns rgba(0, 0, 0, 0) — transparent. The color you see
comes from an ancestor, and often from several stacked translucent
layers. A card at rgba(255,255,255,0.8) over a dark hero is
not white.
The correct approach is to walk up the ancestors, collect every layer until one is opaque, and alpha-composite them from the bottom up. If nothing opaque is found, the canvas behind it all is white by default. Semi-transparent text needs the same treatment against the result.
2. Gradients, either ignored or guessed
A gradient background has no single color, so many tools give up and report "cannot measure" for every word on a gradient header. That is honest but noisy.
There is a better answer that is still honest. The color stops are in the computed value. If the text clears the requirement against every stop, it clears it everywhere along the gradient, whatever the geometry — that is a proof, not an estimate. Only when a stop fails does it genuinely depend on where the text sits, and only then does it need a person to look.
3. Text that is never painted
This is the standard way to give an icon button a screen reader label:
.icon-button {
text-indent: -5000px;
overflow: hidden;
}
The text is in the DOM; its color has a contrast ratio; and it is
irrelevant, because nobody can see it. A checker that reads the
element's box will measure it anyway. Measuring the text node itself
with a Range, and checking that its rectangle survives
every clipping ancestor, avoids reporting a problem that does not
exist.
The mirror image deserves care too: near-white text computed against a
light background almost always means the real dark backdrop is a
positioned sibling or a ::before — not an ancestor at all.
"1.1:1" there is a confident wrong answer. The honest one is "could not
see the background; check by eye".
4. Placeholders, skipped
Grey placeholder text is one of the most common real failures, and it
is often skipped on the assumption that its color cannot be read. In
Chrome it can: getComputedStyle(input, "::placeholder")
returns it, including the browser's own default when the page sets
none. Chrome's default placeholder grey clears 4.5:1 on white; the
custom lighter greys designers reach for usually do not.
A placeholder is not a label either. It disappears when the user starts typing. Passing contrast does not make a placeholder-only field accessible — WCAG 1.3.1 and 4.1.2 still need a real label.
Checking a whole page
A calculator answers one pair at a time. For every text element on a page, A11yScope — a free Chrome extension — composites translucent layers, measures gradients stop by stop, skips text that is never painted, and measures placeholders, then shows each failure with its measured ratio and both colors. What it cannot determine, such as text over a photograph, it reports for review rather than guessing.