A tool that computes the WCAG contrast ratio between a text color and a background and reports AA / AAA pass-fail. It looks like "the ratio of brightnesses," but contrast is computed from relative luminance — not raw RGB, not a channel average — and getting relative luminance right requires undoing the sRGB gamma curve on each channel. Skip that step and your checker reports the wrong ratio for mid-grays. The hinges are that formula and the fact that the pass threshold differs by text size. Fully in-browser.
🌐 Demo: https://sen.ltd/portfolio/color-contrast-checker/
📦 GitHub: https://github.com/sen-ltd/color-contrast-checker
Why a plain RGB ratio is wrong
"White (255) over gray (128) is 255/128 ≈ 2" — wrong, for two reasons:
- Displays apply an sRGB gamma. Pixel value 128 is not 50% physical brightness — it's about 21%. You must undo the gamma to get perceived brightness.
- The eye's sensitivity differs per channel — most sensitive to green, least to blue. So it's a weighted sum, not a plain average.
WCAG defines this precisely as relative luminance.
The hinge: computing relative luminance
Four steps:
// 1 + 2. normalize to 0..1 and undo the sRGB gamma (linearize)
export function linearizeChannel(c255) {
const c = c255 / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
// 3. relative luminance — green has the largest weight (eye most sensitive to green)
export function relativeLuminance({ r, g, b }) {
return 0.2126 * linearizeChannel(r)
+ 0.7152 * linearizeChannel(g)
+ 0.0722 * linearizeChannel(b);
}
// 4. contrast ratio
export function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1), l2 = relativeLuminance(c2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
Point by point:
The linearization branch c <= 0.03928 ? c/12.92 : ((c+0.055)/1.055)^2.4 is the inverse sRGB transfer function — linear in the dark region, a power curve above it. Skip undoing this curve and mid-gray luminance comes out badly off.
The weights 0.2126 / 0.7152 / 0.0722 encode the human luminosity function — green dominates:
test("green is brightest channel", () => {
const g = relativeLuminance({ r: 0, g: 255, b: 0 }); // ≈ 0.7152
const r = relativeLuminance({ r: 255, g: 0, b: 0 }); // ≈ 0.2126
const b = relativeLuminance({ r: 0, g: 0, b: 255 }); // ≈ 0.0722
assert.ok(g > r && r > b);
});
The +0.05 models ambient screen flare. It's what makes pure black (L=0) over pure white (L=1) come out to exactly (1+0.05)/(0+0.05) = 21 — the origin of the 21:1 cap:
test("black on white = 21", () => {
approx(contrastRatio({r:0,g:0,b:0}, {r:255,g:255,b:255}), 21, 1e-2);
});
The difference shows up in mid-grays
The gap between a "good enough" implementation and a correct one is widest at mid-gray. #777777 on white:
- correct: 4.48:1 → just below the normal-text AA threshold (4.5)
- without linearization: the ratio drifts and may falsely pass AA
#777 on white being a "just fails AA" gray is a well-known case, so it's a test:
test("known pair: #777 on white ≈ 4.48", () => {
approx(contrastRatio(parseColor("#777"), parseColor("#fff")), 4.48, 0.05);
});
A 0.02 difference decides AA pass/fail, which is exactly why accuracy matters.
Thresholds depend on text size
WCAG has more than one line:
| context | AA | AAA |
|---|---|---|
| normal text | 4.5 | 7 |
| large text (≥18pt, or ≥14pt bold) | 3 | 4.5 |
| UI components / graphics | 3 | — |
export function wcagLevels(ratio) {
return {
normalAA: ratio >= 4.5, normalAAA: ratio >= 7,
largeAA: ratio >= 3, largeAAA: ratio >= 4.5,
uiAA: ratio >= 3,
};
}
test("4.5 is the normal-AA / large-AAA boundary", () => {
const l = wcagLevels(4.5);
assert.ok(l.normalAA && l.largeAAA && !l.normalAAA);
});
The same 4.5 is the boundary for both normal-AA and large-AAA. The demo shows all five verdicts as pass/fail badges.
Color parsing
Accepts #rgb, #rrggbb, and rgb(r,g,b):
export function parseColor(input) {
let s = input.trim().toLowerCase();
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
if (m) { /* rgb() form */ }
if (s.startsWith("#")) s = s.slice(1);
if (/^[0-9a-f]{3}$/.test(s)) { /* expand #rgb → #rrggbb */ }
if (/^[0-9a-f]{6}$/.test(s)) { /* #rrggbb */ }
return null; // invalid
}
#f80 → {r:255, g:136, b:0} shorthand expansion included; invalid colors return null for a UI error.
Architecture
contrast.js — parse, sRGB linearization, luminance, ratio, WCAG levels (DOM-free, 32 tests)
app.js — live preview + pass/fail table
contrast.js is DOM-free, so all 32 tests run in Node. The UI syncs the native color picker with the text input and reflects fg/bg into a live preview.
Try it
- Demo: https://sen.ltd/portfolio/color-contrast-checker/
- GitHub: https://github.com/sen-ltd/color-contrast-checker
Set #777 text on white: 4.48, "fails normal AA but passes for large text." Darken it gradually to find the moment it crosses 4.5 and you'll build intuition for accessible palettes.
Takeaways
- Contrast is computed from relative luminance, not raw RGB or channel averages.
- Relative luminance requires undoing the sRGB gamma (linearization) — skip it and mid-grays misjudge.
- Weights are 0.2126 / 0.7152 / 0.0722 (green dominant), encoding human sensitivity.
- The
+0.05flare term models ambient light and caps the ratio at 21:1. - Thresholds vary by text size (normal AA 4.5 / large AA 3); the same 4.5 bounds normal-AA and large-AAA.
-
#777on white = 4.48 — the classic "just fails AA" gray. Test it to protect accuracy.
This is OSS portfolio #271 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)