Skip to the article

Measurement Published Reading time 9 min

Why axe reports contrast errors on text you can read

The same 144 scans, six minutes apart, returned 707 serious contrast nodes and then 618 — and the second number is the smaller lie. axe has no concept of time: it fades your text for you, and it passes anything sitting at exactly zero opacity. Both errors are in this repository's stored runs, node for node.

In short

axe measures the frame it happens to catch. It multiplies the text colour by every ancestor's opacity and flattens the result against what is behind it, so a heading one per cent into a fade genuinely is #eaf2f0 on #edf5f3 — 1.02:1 — while the settled heading is near-black and reads fine. An un-reduced sweep of this site, 144 scans over 72 documents at two widths, returned 707 serious colour-contrast nodes across 60 routes; re-run six minutes later under prefers-reduced-motion: reduce, five whole page families dropped to exactly zero. The same condition change uncovered a real defect the first run had hidden, because axe returns a pass for anything sitting at exactly opacity: 0.

What to take away

  1. The scan condition is a measurement variable: the same 144 scans gave 707 serious contrast nodes across 60 routes without reduced motion and 618 across 36 with it, six minutes apart.
  2. axe multiplies the foreground alpha by every ancestor's opacity and flattens the result against the backdrop, so a heading one per cent into a fade measures 1.02:1 — it is not wrong about the pixels, it has no concept of time.
  3. The same blindness runs the other way: an element at exactly zero opacity is treated as hidden and returned as a pass, which hid 420 of the 612 nodes of a genuine 4.36:1 failure.
  4. Diff the two conditions rather than arguing about a single finding — what survives both runs is about colour, and what appears in only one is about timing.
  5. Publish a number that can move: five violations with the element, the ratio, the date, the tool version and the scan condition beside them is a measurement, and zero is a claim.

axe-core 4.11.0 · axe.js:24321-24345

Why does axe report contrast errors on text you can read?

The rule is doing exactly what it says it does. axe-core walks up from the text node through every ancestor stacking context, multiplies the foreground colour's alpha by each one's opacity, flattens the result against whatever is painted behind it, and finally flattens that against white. A heading one per cent of the way into an eight-hundred-millisecond fade is therefore not near-black on paper — it is #eaf2f0 on #edf5f3, and that pair measures 1.02:1 against a 4.5:1 requirement.

That composite is the page ink at roughly one to two per cent opacity, and it can be checked without a luminance formula: #eaf2f0 differs from the paper it sits on by three in each of red, green and blue. Almost all of the ink has been washed out by the compositing step. axe is not wrong about those pixels. It is wrong about the moment, which is a different failure and needs a different fix.

Two more details of the rule matter once a number lands near the floor. axe truncates the ratio rather than rounding it — the floor of the ratio times a hundred, over a hundred — and the pass test is strictly greater than the requirement, so a pair computing to exactly 4.50:1 fails. Both come back in section 06, where a real defect sat fourteen hundredths under the bar.

The two places in axe-core 4.11.0 where a moment becomes a measurement. Read out of the pinned copy at node_modules/axe-core/axe.js and confirmed against the v4.11.0 tag upstream.
// lib/commons/color/get-foreground-color.js — the false positive
function calculateBlendedForegroundColor(fgColor, context, stacks) {
  while (context) {
    if (context.opacity === 1 && context.ancestor) {
      context = context.ancestor; continue;
    }
    fgColor.alpha *= context.opacity;            // the ink is faded
    fgColor = flattenColors(fgColor, bgColor);   // then dropped on the backdrop
    context = context.ancestor;
  }
  return fgColor;
}
 
// lib/checks/color/color-contrast-evaluate.js — the false negative
if (!isVisibleOnScreen(node)) {
  this.data({ messageKey: 'hidden' });
  return true;                                 // an armed reveal is a PASS
}
 
// lib/commons/dom/visibility-methods.js
function opacityHidden(vNode) {
  return vNode.getComputedStylePropertyValue('opacity') === '0';
}

The second block is the half that gets left out of this story, and it is the more dangerous one. A false positive arrives with a selector, an impact and a node count, so somebody argues about it. A false negative arrives as nothing at all.

two stored runs · 6 min 24 s apart

What did changing the scan condition actually change?

Two sweeps of this site sit in tests/.golden-runs/, six minutes and twenty-four seconds apart on 22 August 2026. Both scanned 144 pages: 72 documents at 1440x900 and again at 390x844. Both injected axe-core 4.11.0 from node_modules rather than a CDN, limited to the wcag2a, wcag2aa, wcag21a and wcag21aa tags. The only difference between them is one Playwright context option, reducedMotion set to reduce instead of no-preference.

The first run reported 707 serious colour-contrast nodes across 60 routes — 419 at desktop width, 288 on the phone viewport — in 117 finding groups, every one of them the same rule at the same impact. The second reported 618 across 36 routes. A ninety-node improvement sounds like a rounding error. Family by family it is nothing of the kind.

The same 144 scans, six minutes apart, with only the scan condition changed. Node counts are exact and taken from the two stored runs; those runs are gitignored, so they are first-hand local evidence rather than something a reader can pull from the public repository.
Page family Routes Without reduce Under reduce
Home 3 6 0
Projects index 3 60 0
Disciplines index 3 33 0
Discipline detail 12 292 0
Contact 3 118 0
Case studies 33 192 612
404 3 6 6

Five families went from hundreds of serious nodes to exactly zero. That is 509 nodes of pure noise — text a human reads perfectly, caught mid-reveal by a scanner holding a stopwatch. One family went the other way and went up by 420. One did not move at all.

The control the argument needs is inside the same run. Of the 62 first-party routes in the manifest at the time, the un-reduced sweep found violations on 60; the two it had nothing to say about were the impressum and the datenschutz pages — the only two documents on the site with no entrance-reveal system. The ten migrated demo pages, twenty scans between them, contributed zero of the 707 for the same reason: nothing on them moves.

The case-study family counted 33 routes then rather than the 30 it counts today, because it held ten case studies in three languages plus a standalone explainer page that has since been retired and redirected.

The same question — how much of a reported difference belongs to the page and how much to the harness — has an answer in pixels too: the noise floor of a canvas under visual regression.

axe.js:26148 · 11317 · runs A and B

The other half: axe returns a pass for text at zero opacity

The case-study family reported 192 nodes without reduced motion and 612 with it. That direction is the surprising one, and the mechanism is two lines of the tool. The contrast check opens by asking whether the node is visible on screen; the opacity predicate answers no for any element whose computed opacity is exactly zero; and the check returns true, which is a pass.

Every reveal on this site is armed by a start state of zero opacity, written into the cascade only while an observer is already holding the element. Under no-preference, most of those elements on a case study had never entered the viewport during the scan sweep, so they sat at exactly zero — hidden, skipped, counted as passes. Under reduce the start state is never written at all, so all 612 nodes were evaluated, and all 612 failed.

The harness header comment in this repository gets that the wrong way round, in one sentence of an otherwise unusually careful passage: it says a reveal below the fold sits armed forever from a scanner's point of view and fails on every page it appears on. The opposite happens. An armed reveal is silently passed, and the two stored runs prove it — the family whose reveals mostly never fired returned the smallest number, not the largest.

This is why the popular framing — that axe gives false positives on animation — is a half-truth, and the missing half is the expensive one. A rule with no concept of time is wrong in both directions at once: it invents failures on text that is on its way in, and it excuses failures on text that has not arrived yet.

derived · linear fade · 800 ms reveal

How much of a fade is a guaranteed failure?

The crossing point is computable for any pair, and it is worth computing for your own, because it tells you what fraction of every transition on your site is unscannable. Composite the ink onto the paper at each alpha, apply the WCAG relative-luminance formula, and truncate the way axe truncates.

For this site's tokens the 4.5:1 bar is crossed at opacity 0.5521 and the 3:1 large-text bar at 0.4271. Below those points every frame is a reported serious violation of SC 1.4.3; above them the same element passes, with nothing on the page having changed except how long the scanner waited.

opacity 0.0125 1.02:1
opacity 0.30 2.05:1
opacity 0.50 3.76:1
opacity 0.5521 — the crossing 4.50:1
opacity 0.70 7.71:1
settled, opacity 1 20.36:1
Reported contrast of the page ink on the page paper, through a fade-in.Derived rather than measured: #040504 composited onto #edf5f3 at each alpha, truncated as axe truncates. Bars are to scale against the settled 20.36:1.

On an eight-hundred-millisecond reveal, a linear fade would spend about 442 ms below the normal-text bar and about 342 ms below the large-text one. Treat those milliseconds as an order of magnitude and nothing more — every reveal here is eased, so the real time under the bar differs section by section. The opacity crossings are exact; the durations are an approximation.

The harness then makes the noise inevitable by construction. It scrolls each document in viewport-height steps 60 ms apart, returns to the top, waits 120 ms, and injects axe. An element that entered view on the last step is roughly 180 ms into an eight-hundred-millisecond fade when axe starts building its tree — well short of the 55 per cent crossing, and reported as a serious failure for that reason alone.

The purest case in the set is the page loader's label. Settled it is #040504 on #21f1a8: 13.81:1, more than three times what the rule asks for. It fades out over 300 ms after a 900 ms hold, and the un-reduced run caught it there — six nodes, three homepages, desktop only. Under reduce it is zero in every stored run. Nothing about the page changed. Only the frame.

three guarantees · one gate that checks

Is scanning under reduced motion just hiding the problem?

It would be, if the reduced-motion page were the animated page frozen at its first frame. That is the version of this technique worth being suspicious of: switch the preference on, watch the count fall, publish the smaller number and say nothing about the condition. The question that separates the two is whether the settled state is guaranteed, and whether anything independent checks that it is.

On this site it is guaranteed three ways that do not depend on each other, and a second gate asserts the result rather than trusting it.

  1. The start state is never armed The runtime writes the attribute that hides a reveal only when reduced motion is off. Under reduce the hidden start state never enters the cascade at all, so there is nothing left mid-transition to freeze.
  2. Every target is marked in up front The reveal installer runs with settleImmediately, applying each animation's final frame at once. The motion contract calls that end-state, and it is the required answer for any effect whose result is the content.
  3. The stylesheet forces the settled pose anyway Each of the five section sheets carrying the pattern also sets full opacity and no transform inside a reduced-motion media query, for the case where the preference is switched on mid-session.
  4. The reset layer collapses the durations base/reset.css takes animation-duration and transition-duration to 0.01ms under the preference, and it does so in the reset layer specifically so that nothing further down can out-specify it.
  5. A second gate proves nothing is hidden The older accessibility gate walks every animated hook, split-text element and generated line on the reduced-motion page and asserts that none is at hidden visibility, zero opacity or clipped. The assertion is that the hidden-content count is zero.
What the reduce condition guarantees on this site, and the assertion that keeps it honest.

There is a standards argument as well, and it is the stronger one. The Incidental exception to SC 1.4.3 exempts text that is not visible to anyone, and a heading at one per cent opacity is not visible to anyone. Measuring the settled state is not a way of dodging the requirement; it is the state the requirement is about, and the finding was never one the standard asked for.

This site's own accessibility document pulls the other way, and the tension is worth naming rather than smoothing over. Obligation C-4 says a colour that animates must meet its floor at every sampled point of the transition, not only at its endpoints — which, taken literally, makes the 1.02:1 a real failure. The distinction actually being drawn is between a state a reader is in, such as a scroll-driven colour crossfade measured every 10 px, and a state nobody ever perceives as text.

Reduced motion is a content decision long before it is a testing switch, and the hardest version of it is an animation the preference cannot simply switch off.

project-detail.css:37-46 · not-found.css:53

What survived the correction, and what fixing it cost

Three findings in these runs were about colour rather than about timing, and the way to tell was to diff the two conditions. One of them was large, genuine, and 420 nodes bigger than the first run had said it was.

The case-study accent is the cut of the site's green that stays legible on paper, where it measures 4.82:1. The case-study page prints it on its own card surface, #e2eae7, and against that ground the same pair measures 4.36:1 — under the floor by fourteen hundredths, across 612 nodes and every case-study document. It is exactly the kind of miss a designer never sees and a scanner always does.

The three findings that survived a change of scan condition. The middle row is the control: a static colour on a page where nothing moves returned the same six nodes under both conditions.
Finding Pair and place Bar Measured, then fixed to
Case-study accent as body type #0c7a55 on the card #e2eae7, 612 nodes, every case-study document 4.5:1 4.36:1, then 5.55:1
404 watermark the page ink at 10 per cent over the paper, 6 nodes on 3 routes 3:1 at 70px 1.25:1, then 3.12:1
Certificate heading, accent half #21f1a8 on #edf5f3, 5 nodes on 3 routes 3:1 1.33:1, not fixed

The case-study fix is one line: fifteen per cent of the page's own ink mixed into the accent, which takes it to 5.55:1 on the card and 6.13:1 on paper and changes nothing anyone can perceive. The 404 watermark went from 10 per cent ink to 44 per cent, which is 3.12:1 against the 3:1 bar that applies at 70 pixels, and it stays clearly subordinate to the heading beside it.

Both were fixed inside eight minutes of the run that found them. The next stored sweep, seven minutes and fifty-eight seconds later, reported zero, and the count then held at zero for nine consecutive runs across two days before a decision moved it to five.

The 404 deserves one more sentence, because it is the cleanest control in the set. Nothing on that page moves, so it reported six nodes under both conditions and was not found by changing anything. Only the true size of the case-study defect depended on the condition — 192 reported, 612 real.

The documents those 612 nodes were spread across are the case studies themselves — ten builds, each with its own measurements.

published-measurements.json:33, 56-62

Why does the published figure say five and not zero?

The certificate section's heading sets its second half in the site's own accent, #21f1a8, on the light ground that section is built on. That measures 1.33:1 where 3:1 applies. The token that exists for exactly this case — the accent darkened until it is legible on paper — measures 3.23:1, and solving the contrast equation for that ground caps the accent at #0aa16c, which is indistinguishable from the token that was already shipping.

The owner was shown the measurement first and asked for the accent anyway. So it ships as a decision with its number attached rather than as a tuning: five nodes, one element, three routes, two viewports. It is not covered by any exception either — the logotype clause covers a logo or a brand name, and a section heading in a brand colour is text like any other.

  • 144 scans 72 documents at two widths, WCAG 2.1 A and AA
  • 5 violations one element, three routes, two viewports
  • 4.11.0 axe-core version injected from node_modules, never a CDN
  • 0 screen-reader passes no manual pass has ever been run here
The published record, as contracts/published-measurements.json states it. Every field is re-asserted against the visible copy in three languages on every check run.

The allowance that lets the gate pass is one row naming one rule on one selector. Every other node of the same rule anywhere on the site still fails the build. And it rots on purpose: if that heading is ever fixed, the row stops matching and the run fails until somebody deletes it, because an allowance nothing needs is a lie in the repository.

The matcher has a sharp edge worth stating plainly. It tests only the first failing node of each finding group, so excusing a group excuses every node in it. What bounds that coarseness is the number: the gate prints, and the site publishes, the node total of everything excused, and the total is five. There is no room behind it for anything else to hide.

Paying that bill took three places and a fourth to hold them together — the published record, the visible copy in English, German and Arabic, and the one allowance in the harness, with a facts gate asserting that the scan count, the document count, the tool version and the violation count in the copy still match the record. A number written anywhere a gate cannot reach goes stale, and one in this repository already has.

The page that states the figure to readers, next to the keyboard walk it deliberately excludes, is the four disciplines.

144 scans · 0 keystrokes

What a green scan still does not tell you

A scanner reads markup. It cannot press Tab, it cannot tell you a heading is wrong, and it cannot know that a control is unreachable in practice. What this site publishes is a count of automated violations, and it is never the word accessible.

The rule at issue here has a matching blind spot on the other side of the same page. Deque's own limitations list says colour-contrast will not report on text obscured by another element — and each line of this site's reveal is covered by an opaque bar that wipes away. For the six hundred milliseconds that bar is on screen the text is literally invisible and axe records a pass; for the eight hundred milliseconds a fade is running the text is legible to a human at sixty per cent and axe records a serious failure. Both errors come from the same missing idea.

Run the sweep twice
Once under prefers-reduced-motion: reduce and once without, then diff the two. What survives both runs is about colour. What appears in only one is about timing.
Check what your reveals sit at
Exactly zero opacity is passed as hidden. A scan that never triggers your reveals is not telling you they are fine — it is telling you nothing about them at all.
Compute your own crossing point
Composite your ink onto your paper and find the alpha at which it crosses 4.5:1. That fraction is how much of every transition on your site a scanner cannot read honestly.
Never fix a finding by changing the animation
If the settled colours pass, the finding is about the scanner's stopwatch. Shortening a duration to make a report go green moves the defect instead of removing it.
Publish the node count of anything you excuse
A named allowance with a number beside it cannot quietly grow, and one that stops matching should fail the build rather than sit in the file unnoticed.
How to tell a scan artefact from a defect without arguing about it.

Two more limits belong in the same breath. A route that fails to load produces no findings, and if it sits outside the scored set it does not fail the run either — one stored sweep scanned 143 pages rather than 144 because a demo route timed out at thirty seconds. And a published figure is evidence about one build on one date: the route set here has already changed since 24 August 2026, which is why the record itself says to re-measure before deployment.

For contrast, the old site is the useful counterexample. Its axe run over 40 routes at two viewports returned 379 violation nodes, all colour-contrast, from just four unique colour pairs between 1.26:1 and 1.47:1 — the same accent on four near-white grounds. No mid-reveal noise at all, because that harness read the page in 600-pixel steps and settled for 800 ms before scanning, and its entrances finished. Same tool, same rule, wildly different noise. The scan procedure is a variable, and it should be published beside the number it produced.

If a report on your own build has numbers nobody can explain, that is usually a condition problem rather than a colour problem — talk it through.

Questions

Is a mid-animation contrast finding a bug in axe?

No, and calling it one misses the point. axe measured the frame correctly: a heading one per cent into a fade really is #eaf2f0 on #edf5f3, and that really is 1.02:1. The tool has no concept of time, so it cannot know the frame is transient. Deque's own issue tracker records a maintainer saying as much in 2019 — short of never testing content outside the visible viewport, there is not much to be done about it. The fix is in how you run the scan, not in the rule.

Does WCAG actually require contrast on text that is fading in?

No. The Incidental exception to SC 1.4.3 exempts text that is not visible to anyone, and text mid-fade at one per cent opacity is not visible to anyone. Measuring the settled state is not dodging the standard; it is the state the standard is about. The exception that does not apply is the logotype one, which covers a logo or a brand name — a section heading in a brand accent is text in full.

Does scanning under prefers-reduced-motion just hide the problem?

It would, if the reduced-motion page were the animated page frozen. On this site it is not, and a separate gate proves it: the older accessibility harness walks every animated hook, split-text element and generated line on the reduced-motion page and asserts that none is hidden, at zero opacity or clipped. Under reduce the runtime never writes the attribute that arms the hidden start state, so there is nothing there to freeze.

How do I know a low number is not just the scan not looking?

Mostly you do not, which is the uncomfortable half of this. The runs here show it happening: without reduce the case-study pages reported 192 contrast nodes, and with it 612. The 420 missing ones were sitting at exactly zero opacity, which axe treats as hidden and passes. If a scan does not reach the states your visitors see, a smaller number is not better news — it is less news.

What did the scan condition not change?

The 404 watermark. It reported six nodes without reduced motion and six with it, because it is a static colour on a page where nothing moves. That invariance is the cleanest test available for whether a finding is real: run the sweep under both conditions and diff them. What survives both is about colour, and what appears in only one is about timing.

Sources

Measured in this repository

  • contracts/published-measurements.json The accessibility block: tool, version, command, date, standard, document and scan counts, the violation count, the scan condition, and an explicit list of what was not measured.
  • tools/golden/site-a11y.mjs The sweep itself — two viewports, the one line that sets the motion preference, the scroll pass, the injected axe copy, the bar it enforces, and the single named allowance that fails the run when it stops matching.
  • src/styles/sections/project-detail.css The accent measured at 4.82:1 on paper and 4.36:1 on the case-study card, and the one-line mix that takes it to 5.55:1 and 6.13:1.
  • src/styles/sections/not-found.css The 404 watermark measured at 1.25:1 and set to 3.12:1 at 44 per cent ink, on the one page with no motion on it at all.
  • src/styles/sections/home-certificate.css The certificate heading at 1.33:1, the 3.23:1 token it replaced, the cap the contrast equation puts on that ground, and the decision recorded with its cost.

Checked against

Alaa Abbod

Written by

Alaa Abbod

Creative Developer — Herne, Germany

Designer and developer who builds accessible websites, mobile apps, online stores and visual identities as one job, by hand. This site is published in English, German and Arabic from one source, which is where most of these questions came from.

Please rotate your device,
This is a vertical build.