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.
// 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.
| 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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
| 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 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.
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.