research/wcag-contrast-ratio-in-20-lines
WCAG Contrast Ratio in 20 Lines of Code: Auditing a Dark UI's Color Tokens
Engineering Note · October 15, 2026 · 9 minute read · Accessibility · JavaScript · Design tokens
The WCAG contrast ratio is (L1 + 0.05) / (L2 + 0.05), where L1 is the relative luminance of the lighter color and L2 of the darker one, and luminance is computed from linearized sRGB channels. It ranges from 1 (identical colors) to 21 (black on white). Normal text needs 4.5:1 at level AA, and large text and UI components need 3:1. The math fits in nine lines, which means you can audit a design system’s color tokens in a script instead of eyeballing swatches.
We ran this against the dark theme on our own site. Everything below was executed on Node v22.22.2 with no dependencies, and every number is pasted from real output.
How do you compute relative luminance and contrast ratio?
Each 8-bit channel is scaled to 0-1, linearized with the sRGB transfer function, then weighted by how sensitive the eye is to red, green and blue:
// contrast.mjs
export function luminance(hex) {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function contrast(a, b) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
It takes six-digit hex only. If your tokens are rgb() or shorthand hex, normalize them before they reach this function.
How do you test a contrast function?
Black on white must be exactly 21, any color against itself must be exactly 1, and two published grays sit on either side of the 4.5 line. Gray #767676 on white is the well-known lightest gray that passes AA, and #777777 is the one that just misses.
// test.mjs (assertions trimmed to the reference pairs)
import assert from "node:assert/strict";
import { contrast, luminance } from "./contrast.mjs";
const near = (a, b, eps = 0.01) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`);
near(contrast("#000000", "#ffffff"), 21, 1e-9);
near(contrast("#0b0c0e", "#0b0c0e"), 1, 1e-9);
near(contrast("#777777", "#ffffff"), 4.48);
near(contrast("#767676", "#ffffff"), 4.54);
contrast.mjs: all reference checks passed
black/white = 21.00
identical = 1.00
#777777 on white = 4.48
#767676 on white = 4.54
One detail worth settling: the original WCAG 2.x text uses 0.03928 as the linearization threshold, while the sRGB standard uses 0.04045. We checked whether it matters for web colors. Across all 256 possible channel values, none falls between the two thresholds, so for hex colors the two forms produce identical results. We also ran the nine pairs audited below with both and the largest difference was 0.
What does an audit of real dark tokens look like?
Our dark theme has three surfaces and three text colors. The surfaces are #0b0c0e (bg), #121418 (surface) and #191c21 (raised). The text colors are #9ba1ab (text-2), the old dim color #646b76, and a new dim color #7d8590. The audit checks every text color on every surface, because a token has to work wherever a component can place it.
// audit.mjs
import { contrast } from "./contrast.mjs";
const surfaces = { bg: "#0b0c0e", surface: "#121418", raised: "#191c21" };
const text = { "text-2": "#9ba1ab", "dim (old)": "#646b76", "dim (new)": "#7d8590" };
const mark = (r) => (r >= 4.5 ? "AA" : r >= 3 ? "large only" : "FAIL");
console.log("text".padEnd(10), "on".padEnd(8), "ratio".padStart(6), " 4.5:1 3:1");
for (const [tn, t] of Object.entries(text))
for (const [sn, s] of Object.entries(surfaces)) {
const r = contrast(t, s);
console.log(tn.padEnd(10), sn.padEnd(8), r.toFixed(2).padStart(6), " ", r >= 4.5 ? "pass" : "fail", " ", r >= 3 ? "pass" : "fail", " ", mark(r));
}
text on ratio 4.5:1 3:1
text-2 bg 7.53 pass pass AA
text-2 surface 7.09 pass pass AA
text-2 raised 6.57 pass pass AA
dim (old) bg 3.64 fail pass large only
dim (old) surface 3.43 fail pass large only
dim (old) raised 3.18 fail pass large only
dim (new) bg 5.25 pass pass AA
dim (new) surface 4.94 pass pass AA
dim (new) raised 4.58 pass pass AA
The same results as a table:
| Text color | on bg | on surface | on raised | Verdict |
|---|---|---|---|---|
text-2 #9ba1ab | 7.53 | 7.09 | 6.57 | Passes 4.5:1 everywhere |
dim, old #646b76 | 3.64 | 3.43 | 3.18 | Fails 4.5:1 everywhere, passes 3:1 only |
dim, new #7d8590 | 5.25 | 4.94 | 4.58 | Passes 4.5:1 everywhere |
The old dim color was fine as a large-text or icon color and wrong for body copy. The margin on the new color is thin: 4.58 on the raised surface. Lightening raised by a few points would push it under 4.5 again, so this check belongs in a script that runs on every token change.
What is the minimum lightness that passes 4.5:1?
We wanted to know how close the hand-picked replacement is to the minimum. The approach: convert the old color to HSL, keep hue and saturation fixed, and binary-search lightness upward until the worst surface reaches 4.5:1. Contrast rises monotonically with lightness on a dark background, so binary search is safe.
// solve.mjs
import { contrast } from "./contrast.mjs";
const toHsl = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, d = max - min;
const s = d / (1 - Math.abs(2 * l - 1));
const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [(h * 60 + 360) % 360, s, l];
};
const toHex = (h, s, l) => {
const f = (n) => { const k = (n + h / 30) % 12; return l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
return "#" + [f(0), f(8), f(4)].map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
};
// Smallest lightness, same hue and saturation, that reaches `target` on every surface.
function minLightness(hex, surfaces, target = 4.5) {
const [h, s, l] = toHsl(hex);
let lo = l, hi = 1;
const ok = (x) => surfaces.every((bg) => contrast(toHex(h, s, x), bg) >= target);
for (let i = 0; i < 40; i++) { const mid = (lo + hi) / 2; ok(mid) ? (hi = mid) : (lo = mid); }
return { l: hi, hex: toHex(h, s, hi) };
}
const surfaces = ["#0b0c0e", "#121418", "#191c21"];
const old = "#646b76", hand = "#7d8590";
const [, , l0] = toHsl(old), [, , lHand] = toHsl(hand);
const solved = minLightness(old, surfaces);
const worst = (c) => Math.min(...surfaces.map((bg) => contrast(c, bg)));
console.log("token hex L worst ratio");
for (const [n, c, l] of [["old", old, l0], ["hand", hand, lHand], ["solved", solved.hex, solved.l]])
console.log(n.padEnd(9), c, (l * 100).toFixed(2).padStart(5) + "%", worst(c).toFixed(3));
console.log("lightness points, old -> solved:", ((solved.l - l0) * 100).toFixed(2));
console.log("lightness points, old -> hand: ", ((lHand - l0) * 100).toFixed(2));
console.log("\nladder (same hue/saturation as old):");
const [h, s] = toHsl(old);
for (let p = 0; p <= 12; p += 2) {
const c = toHex(h, s, l0 + p / 100);
console.log(`+${String(p).padStart(2)} pts`, c, worst(c).toFixed(3), worst(c) >= 4.5 ? "pass" : "fail");
}
token hex L worst ratio
old #646b76 42.75% 3.178
hand #7d8590 52.75% 4.579
solved #7c8490 52.44% 4.523
lightness points, old -> solved: 9.70
lightness points, old -> hand: 10.00
ladder (same hue/saturation as old):
+ 0 pts #646b76 3.178 fail
+ 2 pts #69707c 3.424 fail
+ 4 pts #6d7581 3.670 fail
+ 6 pts #727a87 3.944 fail
+ 8 pts #777f8c 4.229 fail
+10 pts #7d8490 4.535 pass
+12 pts #828995 4.849 pass
The solver lands on #7c8490, and the color we picked by hand is #7d8590: one RGB level apart in two channels, with the hand pick 0.056 higher on the worst surface (4.579 against 4.523). The hand pick was within a hair of the mathematical minimum, and the script is what proves it.
How far apart are the old and new colors?
Measured in HSL lightness points, where one point is one percentage point of L, the old color sits at 42.75% and the new one at 52.75%. That is 10.00 points, against 9.70 points for the exact minimum. The ladder shows where the line is:
| Lightness added | Hex | Worst-surface ratio | 4.5:1 |
|---|---|---|---|
| +0 pts (old) | #646b76 | 3.178 | fail |
| +4 pts | #6d7581 | 3.670 | fail |
| +8 pts | #777f8c | 4.229 | fail |
| +10 pts | #7d8490 | 4.535 | pass |
| +12 pts | #828995 | 4.849 | pass |
The text stays a muted blue-gray. A designer worried that “dim” will stop looking dim can see that the new color still sits well below text-2 on the base surface: 5.25 against 7.53.
Why fix the token instead of the elements?
When we ran an automated audit on our own pages, it reported 34 failing nodes. They were not 34 separate mistakes. All 34 shared one token, the old dim color. Patching nodes would mean 34 edits, 34 chances to pick a slightly different gray, and a bad value still sitting in the stylesheet for the next component to use. Changing the token was one edit that fixed all 34 and repaired every future use. The script above is what lets us choose the replacement with evidence.
The practical rule: when a contrast tool lists many failures, group them by computed color before touching any markup. Usually a handful of tokens explain most of the list.
What this does not catch
A contrast script is narrow on purpose, and so are the automated tools that wrap the same math. They cover only part of accessibility.
- Text over images, gradients and blur. The ratio is defined between two flat colors. A caption on a photo needs a different check, such as sampling the worst pixel region.
- Transparency.
rgba()text or surfaces must be composited against what is behind them before the formula applies. The script above refuses anything but opaque hex for that reason. - State colors. Hover, focus, disabled and visited states are separate colors with separate ratios. Disabled controls are exempt from the requirement, which is not the same as being fine.
- Non-text contrast. The 3:1 column covers borders and icons only if you feed it those colors. Nothing here finds them for you.
- Everything that is not color. Keyboard order, visible focus, accessible names, heading structure, motion and plain-language content are outside the math entirely. A page can score perfectly here and still be unusable with a keyboard.
For the keyboard and screen reader side, we run axe in a browser test and then use the page by hand. See the related reading below for the first half.
Related reading
- Automating accessibility checks with axe and Playwright
- Design systems for small teams
- Building an accessible mobile menu without a framework
Frequently asked questions
How do you calculate the WCAG contrast ratio in code?
Convert each sRGB channel to 0-1, linearize it (divide by 12.92 below 0.03928, otherwise raise ((c + 0.055) / 1.055) to the 2.4 power), then weight the channels 0.2126, 0.7152, 0.0722 to get relative luminance. The ratio is (lighter + 0.05) / (darker + 0.05). Black on white is exactly 21.
What contrast ratio does WCAG require for text?
WCAG 2.x level AA requires 4.5:1 for normal text and 3:1 for large text and for UI components such as input borders and icons. Level AAA raises normal text to 7:1. Large text means roughly 18pt regular or 14pt bold and up. These thresholds apply to the ratio, rounded as the spec defines it.
Why does 0.03928 appear in some contrast code and 0.04045 in others?
The 0.03928 value is in the original WCAG 2.x definition, and 0.04045 is the value from the sRGB standard itself. For 8-bit colors the choice never matters: we checked all 256 channel values and none falls between the two thresholds, so every hex color gives an identical ratio.
Should you fix low contrast per element or per design token?
Fix the token. If dozens of elements share one color variable, one change repairs all of them and keeps future elements correct by default. In our own audit, 34 failing nodes traced back to a single dim-text token. Per-element patches leave the bad value available for the next component to reuse.
Does a passing contrast ratio mean a page is accessible?
No. Contrast is one measurable slice. Automated checks do not cover keyboard order, focus visibility, screen reader names, motion, readable text over images or gradients, or whether content makes sense. Treat a clean automated run as the floor, and test the rest with a keyboard and a screen reader.