Frontend Accessibility Audit
Automated tools catch roughly a third of real accessibility barriers. They verify that attributes exist, not that the experience works, so this skill pairs a static scan with the manual checks that find the rest — and produces fixes, not just a list of violations.
The European Accessibility Act deadline in June 2025 made this a procurement requirement in many markets rather than a nice-to-have, so frame findings in terms of legal exposure and user impact, not just score.
Step 1 — Scan
node scripts/a11y_scan.mjs <src-dir>
Catches the static tier: images without alt, form controls without labels, positive
tabindex, click handlers on non-interactive elements, aria-* on elements that do not
accept them, redundant roles, missing lang, heading skips, and generic link text.
For a running app, add runtime checks — they catch computed contrast and focus order that source scanning cannot:
npx @axe-core/cli http://localhost:3000 --tags wcag2a,wcag2aa,wcag22aa
In development, @axe-core/react logs violations to the console as you build, which is far
cheaper than finding them in an audit.
Step 2 — The manual checks that matter most
These four find the barriers that automation structurally cannot:
Keyboard-only pass. Unplug the mouse. Tab through the entire flow. Every interactive element must be reachable, operable with Enter/Space, and visibly focused. Focus must never enter a hidden element or leave the page in an unrecoverable state. Custom widgets need their expected keys — Arrow keys in menus, tabs, and listboxes; Escape to dismiss. If you cannot complete the main task with the keyboard, nothing else in the audit matters.
Focus management on dynamic changes. When a modal opens, focus moves into it and is
trapped there; when it closes, focus returns to the trigger. After a route change, focus
moves to the heading or a skip target rather than staying on a removed element. After
deleting a row, focus lands somewhere sensible instead of on document.body.
Screen reader pass. VoiceOver (Cmd+F5) or NVDA. Read the page top to bottom. Does the heading outline describe the page? Are buttons announced with their purpose rather than "button"? Are errors announced when they appear? Does a live region announce async results without interrupting?
Zoom and reflow. 400% zoom at 1280px width, and 320px viewport. No horizontal scrolling, no clipped content, no overlapping text (WCAG 1.4.10). Then override text spacing (1.4.12) and confirm nothing truncates.
Step 3 — The rules behind most findings
Semantics before ARIA. A <button> gets focus, keyboard activation, and the right role
for free. <div role="button" tabindex="0"> requires you to reimplement all three and
still loses platform behaviors. The first rule of ARIA is not to use ARIA when HTML has an
element for the job.
Name, role, value. Every control needs an accessible name (visible label, aria-label,
or aria-labelledby), a correct role, and an exposed state. Icon-only buttons are the most
common failure — an icon has no accessible name unless you give it one.
Focus visibility. Never outline: none without a replacement. WCAG 2.2 added 2.4.11
Focus Not Obscured, so verify sticky headers and toolbars do not cover the focused element.
:focus-visible gives mouse users a clean UI and keyboard users a real indicator.
Contrast. 4.5:1 for body text, 3:1 for large text and for UI component boundaries and states (1.4.11) — the second one catches disabled buttons, input borders, and focus rings that most teams miss. Color must never be the only carrier of meaning.
Forms. Label every input with <label for>. Associate errors with aria-describedby
and make them programmatically discoverable, not just red. Group radios and checkboxes in a
<fieldset> with a <legend>. Set autocomplete on personal-data fields (1.3.5).
Live regions. aria-live="polite" for status, assertive only for errors that stop
the user. The region must exist in the DOM before the content is inserted, or nothing is
announced.
WCAG 2.2 additions worth checking explicitly, since older audits miss them: 2.4.11 Focus Not Obscured, 2.5.7 Dragging Movements (provide a non-drag alternative), 2.5.8 Target Size 24×24 CSS pixels minimum, 3.3.7 Redundant Entry, 3.3.8 Accessible Authentication (no cognitive test such as transcribing a code without a paste alternative).
references/patterns.md has correct implementations for modal, combobox, tabs, disclosure,
menu, toast, and data table — read it before hand-rolling any of these, since each has a
specific expected keyboard contract.
Step 4 — Report
## Summary
Scope tested, method (automated + manual + AT used), overall risk.
## Blockers — task cannot be completed
### 1. Checkout button unreachable by keyboard
- **WCAG:** 2.1.1 Keyboard (Level A)
- **Where:** `src/checkout/PayButton.tsx:31`
- **Who it affects:** keyboard and switch users — cannot purchase
- **Why:** div with onClick, no tabindex, no role, no key handler
- **Fix:** [working code]
- **Verify:** Tab to the control, press Enter, confirm the order submits
## Serious | Moderate | Minor
[same structure]
## Passed
What was tested and works — this is what an accreditor or procurement reviewer asks for.
Order by user impact, not by rule count. One unreachable checkout button outranks fifty contrast warnings on decorative text, and a report that leads with the fifty gets ignored.
Prevention
eslint-plugin-jsx-a11y in the lint step, jest-axe or axe-playwright asserting zero
violations on key flows in CI, and a keyboard pass in the PR template for any new
interactive component. Accessibility that is not enforced regresses with the next sprint.