DEV Community

SEN LLC
SEN LLC

Posted on

Building a WCAG Contrast Checker — Relative Luminance, sRGB Gamma, and the Step Most Implementations Skip

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

Screenshot

Why a plain RGB ratio is wrong

"White (255) over gray (128) is 255/128 ≈ 2" — wrong, for two reasons:

  1. 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.
  2. 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);
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

#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
Enter fullscreen mode Exit fullscreen mode

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

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.05 flare 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.
  • #777 on 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)