Tabpath Research · 17 September 2026 · A correction to our own work

Phantom Contrast

Our scanner reported 118 colour-contrast failures on a storefront that has five. The other 113 were text it measured while the text was still fading in.

Method axe-core 4.13 · WCAG 2.0/2.1/2.2 A + AA Sample 18 storefronts, each measured twice Design Paired, same browser build Reproducible Recipe below
18storefronts, each measured twice, minutes apart, same browser build
550reported contrast failures that vanish on the eight affected storefronts once the page is scrolled first
86%of everything those eight sites reported — the other ten did not move
10sites where the number barely moved — the control

This is a correction. We built a scanner, pointed it at 921 e-commerce storefronts, 882 of which returned usable results, ranked them by how badly they failed colour contrast, and started emailing the worst offenders free patches. Then we looked closely at one of them and found the site was fine. The scanner was wrong.

We are publishing the mechanism because it is not our bug. It is a property of how an automated scanner measures a page at a moment in time, and it silently inflates contrast numbers on a large fraction of modern storefronts. If you have ever run a scan, printed the number, and forwarded it to a developer, some part of that number was probably not real.

The site that was fine

kadenspaws.com is a Shopify store selling dog supplements. Our scan reported 135 WCAG failures, 118 of them colour contrast. That put it near the top of our shortlist, so we ran our contrast repair over it and prepared an email carrying the patch.

Before sending, we read the individual findings rather than the total. They looked like this:

#fdfdfd on #ffffff   1.01:1   38px   em:nth-child(2) > strong
#244338 on #234237   1.01:1   36px   .image-with-text__heading
#c4c0b6 on #faf5ea   1.67:1   20px   .text-feature__item .h4

Near-white on white. Dark green on the same dark green. Nobody designs that. So we loaded the live page and read the computed styles off the actual elements:

.vertical-scroll-item.h0   color: rgb(35, 66, 55)   on rgb(255,255,255)
.image-with-text__heading  color: rgb(255,255,255)  on rgb(35, 66, 55)
.text-feature__item .h4    color: rgb(35, 66, 55)   on rgb(250,245,234)

Dark green on white, at roughly 11:1. White on dark green. Perfectly legible, comfortably past the 4.5:1 threshold. The site was never failing. We were measuring it wrong.

The mechanism

Modern themes fade content in as you scroll. An element below the fold sits parked at its initial animation state — typically a low opacity — waiting for an IntersectionObserver to fire and reveal it. We should be precise about how we know: we never caught an element mid-fade. The mechanism is inferred from the blended foreground colours axe reports, which are what a partly transparent element resolves to.

A scanner loads the page, waits a few seconds for scripts to settle, and runs. At that moment every element below the first screenful is still parked. And axe-core, correctly and by design, accounts for opacity when it computes contrast: it blends the element's colour toward whatever is behind it before measuring. Text at opacity: 0.01 over white is measured as almost exactly white — against white.

Paw up, if your dog has:
What the visitor sees 11.0:1 #234237 on #ffffff. Passes AA with room to spare.
Paw up, if your dog has:
What the scanner measured 1.01:1 The same element at opacity: .01, mid-reveal. Blended, it computes as #fdfdfd on #ffffff.

Both panels contain the identical heading, in the identical colour. The second is rendered at the opacity the scanner caught it at. This is not a rendering trick for the article — it is the same CSS property the theme animates.

The giveaway is the ratio itself. A contrast ratio below about 1.1 means the text and its background are within a couple of RGB steps of each other. That is not a design mistake anybody ships. A ratio under 1.1 is a measurement, not a defect.

The experiment

One site proves nothing. So we measured the same URL four ways on the same browser build, minutes apart.

kadenspaws.com · axe-core 4.13 · WCAG A + AA
ConditionAll WCAGContrast of which ratio < 1.1
Load, settle, scan — no scroll (what we shipped)135118107
Scroll the whole page first, then scan2350
Emulate prefers-reduced-motion32150
Both2250

Scrolling the page before scanning removes 113 of the 118 reported contrast failures and every single sub-1.1 ratio. Reduced-motion emulation also removed every sub-1.1 ratio, but left 15 contrast findings against the scroll pass’s 5. We have not established what those extra 10 are, so we are not going to explain the gap.

Eighteen storefronts, measured twice

If scrolling first simply suppressed findings, it would suppress them everywhere. The interesting result is that it does not. We took eighteen storefronts — the emailable shortlist plus the four we had already sent patches to — and measured each one twice, minutes apart on the same browser build: once the way we had been shipping, once with a full-page scroll before the scan.

Sites where the scroll pass changed the number
Storefront Contrast, as shipped Contrast, scrolled first Removed Sub-1.1 ratios in the original
kadenspaws.com1185113107
sleepshop.ca9018990
luxuryofhomes.com8808888
kitchenshelves.com7107171
atlaseca.com7016970
grupostop.com70254544
thedecorcircle.com67254266
barfgold.de65323346
8 sites63989550 (86%)582
Sites where it did not — the control
Storefront Contrast, unscrolled pass Contrast, scrolled first Change Sub-1.1 ratios in the original
tigerfitness.com182184+20
betinababy.com15615600
animax.ro129126−30
rustorange.com939300
feedpetaluma.com747400
jennaraecakes.com6665−10
licorice.com636300
magnoliamorango.com626200
nutriment.co.uk4400
keeperstop.com0000
10 sites829827−2 (0.2%)0

Two control rows need a caveat we would rather state than bury: keeperstop.com and nutriment.co.uk reported 114 and 65 contrast failures in the census a few days earlier, and measure 0 and 4 in the unscrolled pass above. That drift is not scroll-reveal — those sites changed, or the earlier scan caught something we cannot reconstruct. They enter the control totals at the lower figures. The correction is surgical, not a blanket suppression. On ten of the eighteen storefronts it moved the total by two failures across 829 — noise. On the other eight it removed 86% of everything reported. The column that separates the two groups perfectly is the last one.

The diagnostic, in one line

Across the whole sample, every site with a sub-1.1 contrast ratio in its findings was affected, and no site without one was. If your scan report contains ratios near 1.0, it is measuring animations. If it does not, your numbers are probably sound.

How far it reaches

Across our 882-storefront census, 632 sites reported at least one contrast failure. Of those, 107 — nearly 17% — show at least one sub-1.1 ratio in the findings we retained. Those 107 sites account for 3,284 of the 10,998 contrast instances in the whole census, about 30%. That is the total reported on affected sites, not an estimate of how many of those instances are phantoms — kadenspaws alone contributes 114 of it, of which this article establishes that 5 are real.

That is a sample-based estimate and it is biased low, for a specific reason: we kept only a handful of example findings per rule per site, and those examples skew toward elements early in the document — which is to say above the fold, which is to say exactly where the artifact does not occur. The true figure is higher. We are not going to guess by how much; we are re-running the census through the corrected method instead.

Why this was worse than an ordinary bug

We were not just reporting inflated numbers. We were ranking by them.

Our merchant shortlist selected storefronts where colour contrast made up 60% or more of all WCAG failures, on the reasonable theory that those are the sites a contrast repair can help most. But a high contrast share is precisely the signature of this artifact — an affected site reports a wall of contrast failures and little else. The filter that was supposed to find our best prospects was selecting for our worst measurements. Of the fourteen emailable stores on that shortlist, eight were phantoms.

If you rank anything by a metric, check that the metric's failure mode does not correlate with the top of your ranking. Ours did, perfectly, and it took an unrelated moment of suspicion to notice.

Reproduce it

Take any storefront whose theme fades content in on scroll. Run your scanner twice.

// 1. what most scanners do
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(8000);
const before = await new AxeBuilder({ page }).withTags(TAGS).analyze();

// 2. the same page, revealed first
await page.evaluate(async () => {
  const step = Math.max(200, window.innerHeight * 0.8);
  for (let y = 0; y < document.body.scrollHeight; y += step) {
    window.scrollTo(0, y);
    await new Promise(r => setTimeout(r, 350));
  }
  window.scrollTo(0, 0);
  await new Promise(r => setTimeout(r, 1200));
});
await page.waitForTimeout(3000);
const after = await new AxeBuilder({ page }).withTags(TAGS).analyze();

Then count how many findings in before carry a contrast ratio below 1.1. That number is your over-report.

What we changed, and what we are not claiming

Our scanner now walks the full page height before axe runs, so every observer fires and every reveal finishes. It costs between about five and nine seconds per scan depending on how long the page is — that is derived from the step size and the waits in the code, not from a timing run. The four storefronts we had already emailed patches to were all in the control group, so those numbers stood and nothing had to be retracted — which was luck, not diligence.

Limits of this finding

  • Eighteen sites is a small sample. The effect within it is unambiguous and the mechanism is verifiable from first principles, but the prevalence figure is an estimate from one census of one kind of site — e-commerce storefronts, heavily weighted toward Shopify.
  • The scroll pass is not complete. On thedecorcircle.com it removed 42 of 67 findings and left 24 sub-1.1 ratios behind. Content that reveals on hover, inside a carousel that only advances on a timer, or in a loop that never settles will still be caught mid-animation. Scrolling is a large improvement, not a guarantee.
  • We have not tested other tools. The mechanism is a property of running axe-core against a page at a moment in time, so any tool that does that is exposed in principle. We have measured it in ours. We have not run the same experiment against Lighthouse, WAVE, pa11y or the commercial platforms, and we are not going to assert what their numbers do until we have.
  • This cuts one way only. Every phantom we found was a false positive. Nothing here suggests scanners miss real contrast failures because of animation — and the deeper limit is unchanged: automated testing reliably reaches about a third of WCAG, whatever it is measuring.

If you are holding a scan report right now

Open the individual findings, not the summary. Sort by contrast ratio. Anything below about 1.1 is almost certainly text your visitors read without difficulty, measured while it was invisible. Subtract those before you brief anybody, quote anybody, or budget anything — and be a little more suspicious of the total than the tool invites you to be.

We are re-running our full census through the corrected method. When it finishes we will publish the corrected prevalence figure here, including if it is less interesting than the one we published before.