Skip to content
accessibilityweb-developmentengineering

The Contrast Failures Automated Tools Miss

Shakewell ·

We recently built a WCAG contrast audit that walks every page of this site in both light and dark themes and reports any text below AA. The first run found 21 failures.

Fourteen of them were not real. They were artefacts of how the checker itself worked.

That ratio is the interesting part, because the same two mistakes are baked into how most contrast tooling behaves — and they are the reason automated accessibility scores are trusted more than they deserve to be.

Why generic checkers get contrast wrong

Contrast is a relationship between two colours: the text, and whatever is actually behind it. The second half is where it falls apart.

An element’s own background-color is frequently not what sits behind its text. It might be transparent, inheriting from an ancestor several levels up. It might be semi-transparent, in which case the real colour is a blend. It might be a gradient, which has no single value at all.

Resolving that properly means climbing the ancestor chain, compositing alpha at each layer, and handling the case where the answer is a range rather than a colour. Most tools read the element’s own background and stop.

Mistake one: measuring mid-transition

Our first run reported the same component with a slightly different colour on every page. #262525 here, #777575 there, #777675 somewhere else.

A stable component does not have three colours. Those numbers were blends of two themes, captured while the CSS transition between them was still running.

Two things were transitioning at once: the site’s theme swap animates colour for 200ms, and Tailwind’s transition-colors utility runs 150ms on individual elements. The audit was reading computed styles about 60ms after flipping the theme — comfortably inside both.

Every one of those readings was a colour that never exists at rest. The fix is to freeze animation before sampling anything:

*, *::before, *::after {
  transition: none !important;
  animation: none !important;
  transition-duration: 0s !important;
  animation-duration: 0s !important;
}

That alone eliminated 14 of the 21 findings.

The tell: if a checker reports the same selector with slightly different values across pages, it is sampling mid-animation. Real failures are identical every time, because the colours are.

Mistake two: decoration read as background

The remaining findings included several at exactly 1.00:1 — meaning the text and its background were computed as the same colour.

The culprit was an animated underline. The link style paints it with a gradient:

background-image: linear-gradient(currentColor, currentColor);
background-size: 0% 1.5px, 100% 1px;
background-position: 0 100%;

That is a one-pixel strip at the bottom of a 28-pixel link, using currentColor so it matches the text. The audit saw a gradient on the element, treated its colour stops as the background, and scored the text against itself.

A perfect 1.00:1 on a link that is perfectly legible.

The fix is to ask whether a background layer actually covers the element before treating it as a backdrop — check the computed background-size and ignore layers whose height is a couple of pixels against a much taller box. Underlines, rules and decorative strips all fall out at that test.

What we deliberately do not flag

Content hidden from assistive technology. WCAG 1.4.3 exempts decorative content, and aria-hidden="true" is the machine-readable declaration of it. Our site has a scrolling marquee of decorative separator dots at 1.66:1 — genuinely low contrast, and genuinely not text anyone needs to read. Its container is aria-hidden, so it is exempt.

Worth checking the whole ancestor chain rather than the element, though. The dots themselves carry no attribute; their grandparent does.

Gradients scored at their best point. Where text sits on a real gradient, every colour stop gets composited and scored, and the worst one is reported. Text has to be legible across the whole element, not at the end that happens to be dark. A checker that averages a gradient will pass a pill whose left half is unreadable.

Prove the checker still works

The failure mode of fixing false positives is a checker that reports “clean” because it has stopped detecting anything. Ours went from 21 findings to zero across 108 routes, which is exactly the result you should not take at face value.

So we tested it against fixtures with known answers — two that must fail, three that must pass:

Fixture Expected
Grey text on white flag
Semi-transparent text over a light background flag
Near-black on white clean
Low-contrast text under aria-hidden clean
Text with a gradient underline clean

All five behaved. A clean result now means something, because we know the detector is live.

This is the part most teams skip. An accessibility score that has never been shown to detect a real failure is not evidence of accessibility — it is evidence that a script ran.

What to take from this

If you rely on an automated accessibility report, three questions are worth asking of it:

  1. Does it resolve the actual background — climbing ancestors, compositing alpha, handling gradients — or does it read the element’s own colour and stop?
  2. Does it freeze animation before sampling? If not, some proportion of its findings are colours that never exist.
  3. Has anyone confirmed it can detect a failure? A green report from an untested checker and a green report from a working one look identical.

Automated tooling catches real problems and is worth running. It is a floor, not a verdict — and on contrast specifically, the gap between “the tool passed” and “a person can read it” is wider than the score suggests.


Related: Accessibility compliance · UX / UI design

Common questions

Why do automated contrast checkers report false positives?

Two common causes, both of which we hit on our own site. Sampling a colour mid-transition catches an element part-way through a hover or fade and measures a colour that never actually renders at rest. And a decorative gradient — an underline, a rule, a glow — gets read as the element's background when it does not cover the element at all. Two thirds of our checker's first findings were wrong for one of those reasons.

What contrast issues do automated tools genuinely miss?

Anything that depends on state or context: text over an image whose brightness varies, colour that only appears on focus or hover, and content that meets the ratio in light mode and fails in dark. Automated passes are necessary and they are not sufficient — the failures that reach users are usually the ones a static scan never sees.

Does all text need a 4.5:1 contrast ratio?

No. WCAG sets 4.5:1 for normal text but 3:1 for large text, which is roughly 24px or 19px bold and above, and content hidden from assistive technology with aria-hidden is out of scope entirely. A checker that applies 4.5:1 everywhere will generate findings that are not real, and the noise is what makes teams stop reading the report.

Start a conversation

Start a conversation

Tell us what you want to build, fix or scale — we’ll come back with a clear way forward.