Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

Checking a whole palette for WCAG contrast in 20 lines of JavaScript

Most of us check contrast one pair at a time: open the DevTools color picker on a paragraph, glance at the ratio, move on. That catches body text. It misses the pairs nobody inspects: the label on the primary button, the

Most of us check contrast one pair at a time: open the DevTools color picker on a paragraph, glance at the ratio, move on. That catches body text. It misses the pairs nobody inspects: the label on the primary button, the muted caption sitting on a card instead of the page, the outline of an input.

Here is the formula, a short script that checks every pair a palette actually uses, and what it found on two demo palettes I had lying around.

The formula

WCAG 2.2 defines contrast from relative luminance. Linearize each sRGB channel, weight them, then compare the lighter and darker color:

const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const ratio = (a, b) => {
  const [x, y] = [lum(hex(a)), lum(hex(b))];
  return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
};

The thresholds you care about:

  • 4.5:1 for normal text (WCAG 1.4.3, level AA).
  • 3:1 for large text: 24px and up, or about 18.66px (14pt) bold and up.
  • 3:1 for the visual boundary of UI components, like an input's outline, when that boundary is what tells you the control is there (WCAG 1.4.11).

Check pairs, not colors

A palette is not "accessible" or "not accessible". Pairs are. So list the pairs your UI really draws:

const palette = {
  primary: '#8b5cf6', accent: '#34d399', background: '#0c0a14', surface: '#17141f',
  text: '#ece9f5', muted: '#9a94ad', border: '#2a2536',
};
const pairs = [
  ['text', 'background', 4.5], ['muted', 'background', 4.5], ['muted', 'surface', 4.5],
  ['#ffffff', 'primary', 4.5], ['primary', 'background', 4.5], ['border', 'background', 3],
];
const c = (k) => palette[k] || k;
for (const [fg, bg, min] of pairs) {
  const r = ratio(c(fg), c(bg));
  console.log(`${fg} on ${bg}: ${r.toFixed(2)} ${r >= min ? 'ok' : `FAIL (needs ${min})`}`);
}

That palette is from Afterhours, a fictional dark developer-tool site I use for demos. Output:

text on background: 16.41 ok
muted on background: 6.75 ok
muted on surface: 6.24 ok
#ffffff on primary: 4.23 FAIL (needs 4.5)
primary on background: 4.64 ok
border on background: 1.32 FAIL (needs 3)

Failure 1: white text on a violet button

#8b5cf6 is a popular violet (it's Tailwind v3's violet-500). White on it comes out at 4.23:1. That passes for large text but not for a normal 14 to 16px button label.

The surprising fix: the page's own near-black background, #0c0a14, on the same violet gives 4.64:1, which passes. Mid-tone colors often sit right in the zone where neither white nor black wins by much, so test both instead of defaulting to white. The other options are darkening the button a step, or making the label bold and large enough to count as large text.

Failure 2: hairline borders

The Afterhours border (#2a2536 on #0c0a14) is 1.32:1. A light palette I ran, Northwind (a fictional coffee shop), lands almost the same: #ead8c3 on #fffaf3 is 1.34:1.

For dividers between sections that's fine; they're decoration. For a text input whose only visible edge is that border, it fails 1.4.11. Either use a darker border for form controls than for dividers (two tokens, border and input, is exactly what shadcn/ui does), or give inputs a filled background that contrasts with the page.

Failure 3: the highlight color used as text

Northwind has an amber, #f59e0b, in its hero gradient. It's tempting to reuse it for a "new" badge or a highlighted price, but amber text on the cream background is 2.07:1, a clear fail. The same amber as a background with the dark text color on it is 7.59:1. Keep bright accent colors as fills, and put text on them, not the other way round.

One near miss worth knowing

Northwind's muted caption color, #7a6656, is 5.24:1 on the page background. Put the same caption on a card (#f6eadb) and it drops to 4.59:1. It still passes, barely. Surfaces are usually a little closer to your text colors than the page is, so a muted color that's tuned on the background alone can fail as soon as it lands on a card. Check muted on surface, every time.

Where the palette comes from

If you're auditing a site you didn't build, the hard part is getting the palette with roles in the first place: which hex is the button, which is the card. DevTools' CSS Overview panel (More tools > CSS Overview) lists every color and flags low-contrast text, which is a good free start.

I made a Chrome extension, Stylasso, that reads a page's palette with roles and shows each color's contrast against the background, so the pairs above come straight out of it. There's a short guide here: extract the colors from any website, with roles. The script above works fine without it.

Written with help from an AI assistant. Every ratio above was computed with the code in this post and checked against the extension's own output.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.