DEV Community

Denis Omerovic
Denis Omerovic

Posted on • Originally published at getaccessguard.com

6 Accessibility Checks Most Scanners Miss (And How AccessGuard Catches Them)

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.

Where scanners agree, and where they stop

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.

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.

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>
Enter fullscreen mode Exit fullscreen mode

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 click, keydown, keyup, 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.

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: icon labels, counter numbers, status badges, the "Required" asterisk on form labels.

.badge::before {
  content: "New";
  color: #e0e0e0;
  background: #ffffff;
}
Enter fullscreen mode Exit fullscreen mode

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, and icon-style pseudo-elements often produce false positives that teams waive. WAVE, Lighthouse (which uses axe), and Pa11y inherit the same behavior.

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, we capture the pseudo-element's color, background, and font size as a separate entry. The contrast checker then evaluates those entries with the same logic it uses for real elements.

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.

axe-core walks the element stack using document.elementsFromPoint to find the effective background, but the approach has documented edge cases: translucent overlays on modals are included incorrectly, 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.

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 z-order. For images and gradients, contrast varies across the image.

What we do: For transparent backgrounds, we walk up the DOM tree until we find an ancestor with a non-transparent background color. Between 98% and 100% of pages have a reachable opaque ancestor. For background images or gradients, 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.

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

The problem: WCAG 1.4.10 requires content to reflow to 320 CSS pixels without horizontal scrolling. The usual offenders: fixed-width containers, min-width greater than 320px, tables with no responsive wrapper, wide images without max-width: 100%.

Why static checks fall short: You can write static heuristics — look for inline width: 1200px, flag overflow-x: scroll on the body. 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: We run a second browser pass at 320x640 after the main scan. The viewport is resized, 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.

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 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. Tracking sectioning context is harder, so most engines do not bother. axe-core does not reset hierarchy at sectioning boundaries. Neither does WAVE.

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 so invisible elements do not pollute the hierarchy.

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. Most scanners give both cases the same severity. You end up with a dashboard full of "errors" that are often framework false positives.

What we do: Severity depends on signal strength. If a div has role="button", it is already announced as interactive — we skip it. If a div has tabindex="0" and an onclick but no explicit keyboard handler, we demote it from error to notice. If neither role nor tabindex is present, we keep it as a warning 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.

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

Originally published on the AccessGuard blog.

Top comments (0)