Web Accessibility Tools: Which Ones Caught This Site's Real Defects
Accessibility 100 on all 8 pages, and two real defects no scanner flagged. Four defects sorted by which tool found them — and why two were structurally invisible.
Automated accessibility tools catch structural defects in a static snapshot — missing headings, bad contrast, unlabelled controls. They do not catch behaviour over time. This site scores accessibility 100 on Lighthouse across all 8 measured pages, on desktop and mobile, and still shipped two real defects that no scanner flagged. Here is the split, defect by defect.
The tools, and what each class of tool can see
| Tool | Type | Catches | Misses |
|---|---|---|---|
| Lighthouse (bundled with Chrome DevTools) | Automated audit | Headings, contrast, labels, landmarks, alt text | Anything requiring interaction |
| axe DevTools | Automated, in-page | Same class, more rules, better explanations | Same blind spot |
| WAVE (WebAIM) | Automated, visual overlay | Structure shown in place — good for teaching | Same blind spot |
| Accessibility Insights | Automated + guided manual | Adds scripted manual checks for keyboard/focus | Still needs a human to run them |
| W3C WAI tools list | Directory | — | — |
| Browser DevTools contrast picker | Manual, per-element | Exact ratios at the real font size | Only what you point it at |
| Keyboard only (Tab, Shift+Tab, Escape) | Manual | Focus order, traps, escape routes | Nothing — but requires you to do it |
| A screen reader (NVDA, VoiceOver) | Manual | Announcements, live regions, name/role/value | Nothing — but requires you to do it |
The industry rule of thumb is that automated tools catch roughly a third of WCAG issues. That number is abstract until you see which third. Below are four real defects from this codebase, sorted by which tool found them.
The two that automated tools found
Both surfaced in a Lighthouse pass on 2026-08-08 that measured pages added after the original audit — pages nobody had scored before.
1. /blog had no <h1> at all. The index title was rendered with SectionHeading, whose default element is h2. The page had a visible title, looked correct, and stated its subject to no one. Lighthouse's page-has-heading-one audit flags this immediately. The fix was as="h1", plus moving BlogCard titles from h3 to h2 so the outline stayed contiguous — visual classes unchanged, semantic level only.
2. /docs skipped h1 → h3. DocsContent's first section heading was an h3 under the page h1, so the outline jumped a level. Lighthouse's heading-order audit catches this. Fixed to h2.
Both are exactly what automated scanning is good at: static, structural, present in the served HTML, checkable without touching the page.
The near-miss worth calling out is contrast. BlogTag, the cluster pill on every blog card, used text-primary (#465fff) on bg-primary-25 (#f2f7ff) — two shades from the same scale, which reads as obviously safe. At 12px it measures 4.49:1. WCAG AA wants 4.5:1. It missed by a hundredth:
/**
* `text-primary-600`, not `text-primary`: at 12px on `bg-primary-25` the brand
* blue measures 4.49:1, which misses AA by a hundredth. One shade darker is the
* same fix the other brand-tinted small text on the site already carries.
*/
A scanner catches this — contrast is computable from a snapshot. The reason it is a near-miss is that it is invisible to review, to design sign-off and to your own eyes. Nobody spots a hundredth. This is the single strongest argument for running an automated tool at all.
The two that no tool found
3. Five live regions that announced nothing. Every form on this site reported its result the way most React tutorials show:
{state && <p aria-live="polite">{state.message}</p>}
That renders a correct aria-live region with correct copy. Every automated tool passes it, because at scan time the markup is valid. It also announces nothing at all in most screen readers.
The reason is that a live region must be in the accessibility tree before its contents change. What gets announced is a mutation inside a region already under observation. The pattern above mounts the region together with its first message — inserting a whole new subtree, not mutating a watched one. Five forms, five broken announcements, zero tool findings.
The fix is an atom that always renders the monitored node:
export default function FormStatus({ state /* … */ }) {
return (
<>
<p aria-live="polite" className="sr-only">
{state?.message ?? ""}
</p>
{state && (
<p aria-hidden="true" className={/* … */}>
{state.message}
</p>
)}
</>
);
}
Two nodes on purpose. The monitored region is sr-only, which is absolutely positioned and therefore not a flex item — so it adds no gap to the four callers laying fields out with flex flex-col gap-*. The visible copy is aria-hidden so the message is announced once, not read again when the user reaches it in reading order.
4. A dialog that claimed aria-modal and let Tab walk out. The saved-templates panel declared aria-modal="true", which tells assistive technology the rest of the page is inert. Tab did not agree. A screen-reader user was told they could not leave a dialog they could in fact tab straight out of — the worst kind of accessibility bug, because the assistive technology is actively misinforming the user.
No automated tool catches this either. aria-modal="true" is a valid attribute correctly applied; whether keyboard focus honours the claim is a runtime behaviour across many events. The fix wraps focus manually:
const focusables = panel.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && (active === first || active === panel)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
The pattern
Both missed defects share a shape: the markup is correct at every instant, and the behaviour across instants is wrong. A live region is about what changed while something watched. A focus trap is about where the next Tab lands. Automated tools evaluate one snapshot of the DOM, so this entire category is structurally invisible to them — not a gap in any particular scanner, but a limit of the method.
Which yields the practical rule: automated tools verify claims your markup makes; only a human verifies that the page keeps them.
A gap in this repo's own guards
Worth stating plainly, because it is the same lesson. This codebase runs 310 tests across 31 files — and zero of them render a component:
Test Files 31 passed (31)
Tests 310 passed (310)
No jsdom, no Testing Library. Every assertion is a data invariant or a security boundary. npm test will fail if a product is missing its image variants, but nothing fails if a heading level regresses or a live region goes back to conditional mounting. The heading sweep that confirmed all 16 sampled routes have exactly one <h1> was a one-time manual pass, not a regression guard. The image-variant invariant is automated; the accessibility invariants are not. That asymmetry is honest to report and worth fixing.
How to actually run these
Lighthouse against a local production build, which is what produced the scores above:
npm run build && PORT=3100 npm run start &
npx lighthouse@12 http://localhost:3100/ \
--preset=desktop \
--only-categories=performance,accessibility,best-practices,seo \
--chrome-flags="--headless=new" --view
Drop --preset=desktop for the mobile run — worth doing separately, since mobile had never been measured here until that 2026-08-08 pass and was where several findings surfaced.
Then, because the tool above cannot do it: Tab through every dialog, press Escape, and submit one form with a screen reader running. That is a ten-minute pass and it is the only thing that would have caught defects 3 and 4.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Lighthouse says 100 but users report problems | The missed third: live regions, focus order, dynamic states | Add a manual keyboard + screen-reader pass |
| A status message never gets announced | The aria-live node mounts with its first message | Render the live region always, even when empty |
| Screen reader says "dialog" but Tab leaves the page | aria-modal="true" without a focus wrap | Trap Tab/Shift+Tab within the panel; handle Escape |
| Contrast passes by eye, fails an audit | Ratio checked at the wrong size or against the wrong background | Measure at the real font size and real background; go one shade darker |
| A message is read out twice | Both the live region and the visible copy are exposed | Mark the visible copy aria-hidden="true" |
| Scores differ between runs | Lighthouse mobile applies simulated CPU/network throttling | Compare like with like; accessibility scores should not vary |
Frequently asked questions
Which single tool should I start with? Lighthouse, because it is already in Chrome DevTools and needs no install. Add axe DevTools when you want better rule coverage and clearer remediation text. Neither removes the manual pass.
Are accessibility overlay widgets a substitute? No. Overlays that promise one-line compliance do not fix the defects above — a live region that mounts with its message stays broken regardless of what is layered on top. Fix the markup and the behaviour.
Does a 100 accessibility score mean the site is accessible? No, and this post is the evidence. Every page here scored 100 on both form factors while two real defects were live. The score means no automatically detectable failures — a genuinely useful floor, and not a ceiling.
What should be in CI versus done by hand? Automate what is deterministic in a snapshot: heading order, contrast, labels, landmarks. Keep focus management, live-region announcements and reading order manual, on a checklist, run when the interaction changes.
Templates in this post
ASoc Reach (an AI marketing-agency landing page), ASoc Realm (a property-management SaaS site) and ASoc Relay (a messaging-platform landing page) ship the semantic heading structure and focus handling described here.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the individual audits, see Lighthouse Accessibility Score, React Focus Trap and React Toast Notifications.
