Skip to content We're live on Product Hunt today Upvote
install

notes 5 min accessibility

note

Contrast checkers measure a colour you never shipped

Every contrast checker asks for two colours. A real page hands you one of them at best — the other is a gradient beneath a semi-transparent card above a photograph, and not one of those is a hex value anybody typed.

The requirement is simple enough, and the arithmetic is not hard:

1.4.3 — normal text
4.5:1 against its background.
1.4.3 — large text
3:1. Large means 24px, or 18.66px when bold — computed pixels, not the number in the design file.
1.4.11 — non-text
3:1 for the parts of a control that identify it, and for meaningful graphics.

None of that is in dispute. The dispute is over which two colours go into it. A checker takes what you type; a browser paints what the page composited. Those agree on a plain card on a plain background, and part company everywhere else — which is most of the interesting parts of most designs.

The background is composited, not declared #

What sits behind a glyph is the result of stacking every painted layer beneath it: the body colour, a section gradient, a card at 92 percent opacity, an inherited opacity on an ancestor fading the whole subtree, a backdrop-filter blurring whatever is under it. None of those is a value you can read off one element and paste into a form.

Text has the same problem from the other side. color: rgba(0, 0, 0, 0.72) is not black; it is whatever black becomes over that particular background.

checked as 21:1, painted at 8.8:1
.panel   { background: #f4f5f7; }
.panel p { color: rgba(0, 0, 0, 0.72); }

/* The palette was checked as #000 on #fff: 21:1.
   The browser composites #444545 on #f4f5f7: 8.8:1.
   Still a pass - and the margin is now four times smaller
   than the number anybody wrote down. */
what the checker was given

Read any page like you built it

#000000 on #ffffff — 21:1
what the browser painted

Read any page like you built it

0.72 alpha on #f4f5f7 — 8.8:1 0.38 alpha on #f4f5f7 — 2.7:1
The same sentence, the same declared colour. On the right the alpha is doing what alpha does, and the ratio moves from a comfortable pass to a clear failure without one hex value changing anywhere in the stylesheet.

The honest way to get the number is to compute the effective background by walking up the tree, compositing each layer as it goes, stopping at the first fully opaque paint, then applying the alpha of the text over that. That is what the browser did. Anything else is a different question with a similar-looking answer.

Where it goes wrong in practice #

Three failures account for nearly all of them, and all three pass a swatch check.

  1. Dark mode, derived rather than designed. A palette is checked in light mode and the dark tokens are produced by inverting lightness. Mid-greys that had 5:1 against white land at 3.2:1 against a near-black. Both were checked — as swatches, twice, never as a page.
  2. The hero image. Text over a photograph is checked against the photograph's average colour, or against nothing at all. The requirement is not the average: it is met at the worst pixel behind any glyph. A scrim works precisely because it makes the background computable again.
  3. The brand colour used as text. A colour chosen to be seen as a large filled shape is rarely legible at 14px on white. It passes as a button and fails as a link.

One place a low ratio is permitted: WCAG exempts inactive components. Worth knowing before rewriting a palette to fix something that was never a failure.

The criterion everybody forgets #

Contrast is not only about text. 1.4.11 asks for 3:1 on input borders, focus indicators, toggle states, icons carrying meaning on their own, and the lines of a chart.

Focus rings are where this is failed most, and the failure is invisible to everyone who uses a mouse. A ring in the brand colour on the brand background can sit at 1.4:1 and look entirely intentional. A keyboard user cannot tell where they are on the page.

the two-tone ring that survives any background
:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--ring-halo);
}

/* The halo does the work: whatever the ring fails against,
   the second colour behind it passes. */

How the number is actually computed #

Worth knowing, because the shape of the formula explains most of the surprises. Contrast is a ratio of relative luminance, and relative luminance is not brightness as a person perceives it:

the whole of WCAG 1.4.3, in arithmetic
const channel = (v) => {
  const c = v / 255;
  return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};

const luminance = ([r, g, b]) =>
  0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);

const ratio = (a, b) => {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};
  • Green carries 71 percent of the weight. Two colours can be equally dark to look at and land far apart, which is why a mid-green and a mid-blue behave nothing alike as text.
  • The 0.05 floor compresses the dark end. Once a background is near-black, making it blacker barely moves the ratio - and dark-mode palettes are tuned in exactly that region.
  • The exponent punishes mid-tones. The gap between #767676 and #808080 is invisible and it is often the gap between 4.5:1 and 4.3:1.

This is also why a ratio cannot be averaged. Two paragraphs at 4.6:1 and 4.4:1 are not "about 4.5". One passes and one does not, and the requirement is per element.

What to actually do #

  • Check the rendered page, not the palette. The palette is a set of intentions; the page is the artefact.
  • Check both themes. A derived dark palette is a second design, and it has been checked once.
  • Check at the smallest size the text is actually used at — captions, labels, legal lines, placeholder text.
  • Check the focus ring, on every surface it can appear over.

Those four cover nearly every contrast failure that ships from a design system which was, on its own terms, checked.

Written by Ján Turský

Building LoupeKit and other browser tools out of Bratislava, under Apptiary.

tools in this note