Guides ·
Why two contrast checkers give you different numbers
The linearisation step most implementations skip, why it moves mid-tones across the AA pass line, and how to tell which of two disagreeing tools is right.
Almost always because one of them skipped linearisation. WCAG computes relative luminance from gamma-corrected channel values, not from the raw sRGB numbers — and weighting the raw numbers directly reports mid grey on white as 2.03:1 when the correct answer is 4.48:1.
The disagreement, in one row
#777777 on white. Two tools, two answers:
| Method | Ratio | Verdict for body text |
|---|---|---|
| Weighting the raw sRGB values | 2.03:1 | Fails badly |
| WCAG, with linearisation | 4.48:1 | Fails, but only just |
Both numbers fail AA, so this particular pair happens not to matter. One notch lighter and it does:
| Colour on white | Raw sRGB weighted | Correct | AA body text (4.5:1) |
|---|---|---|---|
#767676 | 2.05:1 | 4.54:1 | Passes |
#777777 | 2.03:1 | 4.48:1 | Fails |
#949494 | 1.67:1 | 3.03:1 | Fails (passes for large text) |
#dddddd | 1.15:1 | 1.36:1 | Fails |
#000000 | 21:1 | 21:1 | Passes |
#767676 and #777777 differ by one hexadecimal digit and sit on opposite sides of a legal threshold. A tool that skips linearisation calls both of them catastrophic; the specification calls one of them compliant. Nothing on screen tells you which calculation you are looking at.
Black on white is 21:1 either way, which is why the bug survives casual testing — the extremes agree, and only the middle diverges.
The step that gets skipped
Relative luminance is not a weighted average of the numbers in your hex code. Those numbers are gamma-encoded: sRGB value 128 is not half the light of 255, it is closer to a fifth of it.
Each channel has to be converted back to linear light first:
const linear = (c) => {
const v = c / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
const luminance = (r, g, b) =>
0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
Only then are the weights applied — 0.2126 red, 0.7152 green, 0.0722 blue, which is why green dominates and blue barely registers at all. Drop the linear() call and you have the second column of the table above.
The weights are the part everyone copies correctly. The Math.pow is the part that gets lost.
The other half of the definition
const contrast = (a, b) => {
const x = luminance(...a), y = luminance(...b);
const lighter = Math.max(x, y), darker = Math.min(x, y);
return (lighter + 0.05) / (darker + 0.05);
};
Two things here are load-bearing.
Lighter over darker, always. The ratio must not depend on which colour you passed first. An implementation that divides foreground by background returns the reciprocal half the time — ratios below 1, which no threshold was ever written for, and which usually surface as a tool that reports a passing pair as failing when you swap the swatches.
The 0.05 offsets. They model ambient light reflecting off the screen. Without them, black on anything divides by zero.
Which thresholds actually apply
The numbers are easy to look up and easy to apply to the wrong thing.
| Ratio | Level | Applies to |
|---|---|---|
| 3:1 | AA | Text at 24px, or 19px bold. Also icons, form borders and focus rings |
| 4.5:1 | AA | Body text. The one most legislation points at |
| 7:1 | AAA | Body text, enhanced. Rare outside text-heavy sites |
The 3:1 row is the one that gets missed, because it is not about text. WCAG 1.4.11 applies it to user interface components and meaningful graphics — so a text input whose border is a pale grey on white fails even if every word on the page passes. A control that is genuinely disabled is exempt; one that merely looks disabled is not.
Note also that “large” is a size in CSS pixels, not a heading level. An <h3> set at 16px is body text as far as the specification is concerned.
Transparency does not average
A semi-transparent foreground has no contrast ratio of its own. WCAG is defined over the colours that actually reach the eye, so rgba(0,0,0,0.5) over white has to be composited to #808080 first and that checked. A tool that accepts an alpha channel and reports a number without compositing is answering a different question from the one you asked.
How to tell which tool to trust
Two test pairs settle it in about ten seconds.
#777777on#ffffff. Correct answer 4.48:1. If it says roughly 2, the tool is skipping linearisation.- Swap the two colours. The number must not change. If it does, the tool is dividing in a fixed order.
A tool that passes both is doing the arithmetic properly. The checker here does, and its tests assert against the worked values in the specification rather than against its own output — checking a contrast implementation with its own luminance function proves only that two halves of the same misunderstanding agree.
Why the number is not the whole answer
A ratio is a number, and numbers have no appearance. 4.4:1 and 4.6:1 sit on opposite sides of a legal line and are visually indistinguishable; 8:1 and 15:1 both simply read as “fine”.
So check the number, then look at the text. The ratio tells you whether it complies. Only your eyes tell you whether it is comfortable to read for a thousand words — and that is the question your readers will actually answer.