Accessibility Scanner Comparison: 6 Checks axe and WAVE Miss

axe, WAVE, Lighthouse, Pa11y, and Siteimprove share a rules-engine lineage and share its soft spots. Here are six checks where that baseline tends to under-report, over-report, or punt to manual review, with notes on how we approached each one.


Most popular accessibility scanners share a foundation - either axe-core directly or a similar rules engine that reads the static HTML. That gives them fast, reliable coverage of the obvious issues: missing alt text, empty links, heading order, deterministic ARIA misuse. It also means they all share the same blind spots.

We audited what slips through, then built detection for six of the most common gaps. This post walks through each one, explains why it is hard, and shows what a real fix looks like. If you are using any of the major scanners, use this as a checklist for what to double-check by hand.

Where scanners agree, and where they stop

Run the same page through axe-core, WAVE, Lighthouse, Pa11y, and Siteimprove and you will get broadly similar results for the deterministic rules. That is not a bad thing - the shared baseline catches real problems and catches them fast.

What the shared baseline does not catch is anything that requires one of three things: actual browser behavior (not just the DOM), computed state that is not expressed in HTML attributes, or visual geometry that only exists after layout. That is where every scanner has gaps, and it is where we focused this round of work.

For context on why that shared baseline exists and where axe historically drew its own line, the prior post on full WCAG 2.2 coverage walks through a different set of gaps specific to the WCAG 2.2 additions.

1. onclick handlers attached through JavaScript (2.1.1 Keyboard)

The problem

A common pattern in React, Vue, Svelte, and vanilla JS apps: a <div> with a click handler attached via addEventListener. The HTML looks like this:

<div class="card" data-id="42">Open</div>

No onclick attribute, no role, no tabindex - but in the browser, clicking it runs a handler and so does pressing Enter (if the framework wired up a keydown listener too).

Why static scanners miss it

axe, WAVE, and the rest read the HTML as it arrives from the DOM. They can see onclick="..." as an inline attribute, but they cannot see listeners attached with element.addEventListener("click", ...) because those live in JavaScript state, not the attribute set. So they either miss the problem entirely (no flag on a mouse-only div) or over-report (flag every div with a keydown-less onclick even when the framework attached a keydown listener dynamically).

What we do

Before the page script runs, we inject a small shim that wraps EventTarget.prototype.addEventListener. Every call to attach a clickkeydownkeyup, or keypress handler gets recorded in a WeakMap keyed by the target element. After the page finishes loading, we query that map and tell the scanner which elements actually received keyboard handlers. The onclick-without-keyboard warning only fires on elements that truly have no keyboard path.

The wrapper is marked as native via the same spoofing helper used by our stealth browser layer, so sites that try to fingerprint modified prototypes (Cloudflare, PerimeterX, DataDome) still see addEventListener.toString() === "function addEventListener() { [native code] }".

For a broader look at what WCAG 2.1.1 requires and the patterns that commonly trip developers up, the keyboard navigation guide covers the fundamentals.

2. Pseudo-element text contrast (1.4.3 Contrast)

The problem

CSS ::before and ::after with a content property render real text to the screen, and that text is subject to the same contrast requirements as any other text. Examples you see every day: icon labels, counter numbers, status badges, the "Required" asterisk on form labels.

.badge::before {
  content: "New";
  color: #e0e0e0;
  background: #ffffff;
}

That is 1.13:1 contrast. Invisible. A screen reader will not read it, but the visible rendering still fails WCAG 1.4.3.

Why static scanners miss it

axe-core has attempted pseudo-element contrast since 2022, but the implementation has documented gaps: pseudo-elements without position: absolute are frequently missed (axe-core issue #2680), and icon-style pseudo-elements often produce false positives that teams waive (issue #3431). WAVE, Lighthouse (which uses axe), and Pa11y inherit the same behavior. In practice, pseudo-element contrast bugs are routinely missed in real audits.

What we do

While we are in the browser computing styles for every element, we also call getComputedStyle(el, "::before") and getComputedStyle(el, "::after"). If the resulting content is a non-empty string literal (not none or normal), we capture the pseudo-element's color, background, and font size as a separate entry keyed by selector::before. The contrast checker then evaluates those entries with the same logic it uses for real elements, falling back to the host element's background when the pseudo does not set its own.

If you are hand-auditing pseudo content, the color contrast guide has practical tips for testing. For quick one-off checks, the color contrast checker tool works the same for pseudo-element text as for any other foreground/background pair.

3. Contrast over transparent backgrounds and background images (1.4.3 Contrast)

The problem

Modern layouts use transparent backgrounds constantly. A card inside a section inside the body. A header overlay on a hero image. A navigation bar with a semi-transparent background so the content behind shows through on scroll.

axe-core walks the element stack using document.elementsFromPoint to find the effective background, and most scanners built on axe inherit that. But the approach has documented edge cases: translucent overlays on modals are included incorrectly (axe-core issue #3666), opacity on ancestors is handled inconsistently, and when the effective background is a gradient or image the check either errors out or flags it as needs-review (issue #3390) with no specific guidance on what the real contrast ends up being.

Why this is hard

There is no single correct answer. The "real" background behind transparent text is whatever happens to be rendered at that pixel, which depends on layout, scroll position, and the z-order of every ancestor. For images and gradients, the contrast varies across the image. A static scanner cannot know what is there without rendering the page and sampling pixels.

What we do

For transparent backgrounds, we walk up the DOM tree until we find an ancestor with a non-transparent background color, and use that for the contrast calculation. Between 98% and 100% of pages have a reachable opaque ancestor (the body, at minimum, almost always does), so this works for the common case.

For background images or gradients, we do something different: we emit an honest notice instead of pretending we know the answer. The notice tells you the element sits over a background image and that contrast cannot be reliably computed, with a suggestion to verify manually against the darkest region of the image. That is better than a silent pass and better than a false positive against an assumed white background.

We also check for the intermediate case: an ancestor with a background image sits between the text and the first opaque ancestor. In that case we still emit the notice, because the image is what the user actually sees behind the text.

4. Real 320px reflow at an actual viewport (1.4.10 Reflow)

The problem

WCAG 1.4.10 (Reflow, Level AA) requires content to reflow to 320 CSS pixels without horizontal scrolling. The usual offenders: fixed-width containers, minimum widths greater than 320px, tables with no responsive wrapper, and wide images without max-width: 100%.

Why static checks fall short

You can write static heuristics for this - look for inline width: 1200px, look for min-width: 900px in computed styles, flag overflow-x: scroll on the body. We do all of those. But a lot of reflow failures only manifest when you actually narrow the viewport: a grid that does not collapse, a flex row that does not wrap, content hidden inside a wrapper whose child has a fixed aspect ratio that exceeds the viewport.

What we do

With REFLOW_FULL_CHECK enabled, we run a second browser pass at 320x640 after the main scan. Ferrum resizes the viewport, layout re-runs, and we query document.documentElement.scrollWidth versus clientWidth. If the page requires horizontal scrolling at 320px, it fails 1.4.10. We also enumerate the first 20 elements whose getBoundingClientRect().right exceeds 320px, so you know exactly which containers are the culprits.

This is real behavioral testing, not a static lint. The cost is an extra 3-5 seconds per scan, which is why it is gated behind an environment flag for now. Viewport is always restored to the main resolution afterward, even on errors.

5. Sectioning-aware heading hierarchy (1.3.1 Info and Relationships)

The problem

WCAG 1.3.1 requires a logical heading hierarchy. In practice, scanners flag "skipped heading levels" whenever the next heading is more than one level below the previous one - h1 followed by h3, for example.

That rule is correct at the document level. It is wrong inside sectioning content. A page that has an <article> with its own <h1>, or a card layout with independent heading outlines, is following the HTML5 sectioning spec. Flagging every one of those as a skip level produces 10 to 30 false positives on any page with multiple articles, sidebars, or modal dialogs.

Why most scanners get this wrong

A flat walk over all headings is easy to write and easy to reason about. Tracking sectioning context is harder, so most engines do not bother. axe-core does not reset hierarchy at sectioning boundaries. Neither does WAVE. The result is accurate on simple blog-post markup and noisy on modern card-based layouts.

What we do

We group headings by their nearest sectioning ancestor - <article><section><aside>role="article"role="region" - before running the hierarchy check. Each sectioning root gets its own starting heading level, and skips are evaluated within that scope. We also filter out hidden headings (display: nonearia-hidden="true"visibility: hidden) so invisible elements do not pollute the hierarchy. And the "multiple h1s" rule only counts h1s at the top level, not h1s inside articles.

If you are wrestling with heading structure in a card or component-heavy site, the common accessibility issues guide walks through a few real examples.

6. Framework-aware severity (2.1.1 Keyboard)

The problem

An onclick handler on a plain <div> without any keyboard path is a real bug. An onclick handler on a <div role="button" tabindex="0"> probably is not - that is a standard pattern where the framework has wired up Enter and Space key handlers that the scanner cannot see.

Most scanners give both cases the same severity. You end up with a dashboard full of "errors" that are often framework false positives, which either buries the real bugs or teaches your team to ignore the tool.

What we do

Severity depends on signal strength. If a div has role="button", it is already announced as interactive - we skip it entirely (this is standard behavior for any reasonable scanner, worth stating). If a div has tabindex="0" and an onclick but no explicit keyboard handler, we demote it from error to notice, because the element has explicitly been made focusable and almost certainly has a framework-attached key handler. If neither role nor tabindex is present, we keep it as a warning (not an error) because we genuinely cannot be sure there is no keyboard path.

Combined with the addEventListener tracking from item 1, the false-positive rate on this specific check drops close to zero on modern framework sites, while still surfacing the cases that are genuine bugs.

What to do with this

If you are evaluating accessibility scanners, ask the vendor how they handle each of the six cases above. Not because those are the only things that matter - most scanners cover the deterministic baseline, and they all matter more than these edge cases - but because how a vendor answers tells you how seriously they have thought about the long tail of real-world accessibility.

If you are just trying to make your own site accessible, the short version is:

  • Run any scanner. The shared baseline catches most real issues.
  • Hand-check the categories above on a few representative pages, especially if you use a modern JS framework, CSS pseudo-elements for icons or labels, or card-heavy layouts.
  • Treat scanner notices as signals to investigate, not as failures. Especially for 1.4.3 (contrast) and 1.4.10 (reflow), the "unknown" cases are often where real problems hide.

For a structured approach to running a manual pass on top of an automated scan, the accessibility audit guide has a workflow. For the underlying WCAG vocabulary, the WCAG primer covers the standard end to end. And if you want to see all of this in action on your own site, run a free scan - the report will tell you which of these checks triggered where.

Start scanning for free

Join thousands of developers making the web more accessible.

Get started