Accessibility Audit skill for Claude Code
WCAG 2.2 AA findings you can hand to engineers. Claude runs the audit a specialist would: axe-core and Lighthouse first, then the manual passes automation cannot judge — keyboard order and traps, focus visibility and obscuring, contrast in both themes via a bundled script, ARIA widget behavior, reflow, target size and a screen reader walk through critical flows. Every finding cites its success criterion, location, impact and fix.
- name
accessibility-audit - size208 lines · ≈2,300 tokens
- files3
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Audits web pages and components against WCAG 2.2 Level AA using automated scans (axe-core, Lighthouse) plus the manual checks automation misses — keyboard operation, focus visibility and order, contrast, semantics, ARIA widget patterns, reflow, target size, and screen reader behavior — and writes a prioritized findings report with success criteria and fixes. Use when asked for an accessibility or a11y audit, WCAG or ADA/EAA compliance check, VPAT prep, axe violations, keyboard traps, or screen reader bugs. Try asking
- Run a WCAG 2.2 AA audit on our checkout flow and write up the findings
- Our VPAT is due — map the current issues to success criteria
- Is this custom dropdown accessible? Check it against the ARIA patterns
Use it when
- You need an accessibility audit or compliance check before launch
- Preparing a VPAT, ACR or EAA conformance statement
- axe reports violations and you need them prioritized and fixed
- Users report keyboard or screen reader problems
Skip it when
- The target is a native iOS or Android app — use mobile-accessibility
- Only a single form needs fixing — use forms-validation
How to check the result
Re-run the axe scan and the exact manual steps from each finding after fixes, and add Playwright axe checks so the fixed templates fail CI if they regress.
The common mistake it prevents
Declaring a page accessible because axe shows zero violations, when keyboard traps, focus order and screen reader announcements were never tested.
An audit is done when every page template in scope has passed an automated scan, a keyboard-only walkthrough, a contrast check, and a screen reader pass, and each failure is written up with the success criterion, exact location, user impact, and a concrete fix. Automated tools find roughly a third of issues; the manual passes are not optional.
Scope first
Agree on and record before testing:
- URLs or component list, and the representative templates (home, listing, detail, form, checkout, modal, authenticated dashboard).
- Target: WCAG 2.2 AA (covers EN 301 549 / EAA and most ADA settlements in practice).
- Browsers and assistive tech: VoiceOver + Safari (macOS/iOS), NVDA + Chrome or Firefox (Windows). TalkBack + Chrome for Android when mobile web matters.
- States to cover: logged in/out, empty, error, loading, expanded menus, open dialogs.
Workflow
- [ ] 1. Automated scan of every template and key state
- [ ] 2. Keyboard-only walkthrough
- [ ] 3. Focus visibility, order, and obscuring
- [ ] 4. Contrast: text, UI components, focus indicators, both themes
- [ ] 5. Semantics: landmarks, headings, names, roles, states
- [ ] 6. Custom widgets against ARIA APG patterns
- [ ] 7. Zoom, reflow, text spacing, target size, motion
- [ ] 8. Screen reader pass on critical flows
- [ ] 9. Write the report; propose fixes in priority order
1. Automated scan
# Quick CLI scan of a running site
npx @axe-core/cli http://localhost:3000 http://localhost:3000/checkout \
--tags wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa --exit
# Lighthouse accessibility category only
npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json --output-path=./a11y.json
For states behind interaction, scan inside Playwright tests:
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("checkout dialog has no axe violations", async ({ page }) => {
await page.goto("/cart");
await page.getByRole("button", { name: "Checkout" }).click();
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
Treat “needs review” items as manual checks, not passes.
2. Keyboard walkthrough
Unplug the mouse mentally. For each template:
Tab/Shift+Tabreaches every interactive element, in an order matching the visual reading order (2.4.3). Nothing non-interactive receives focus.- A skip link is the first focusable element and moves focus to
<main>(2.4.1). Enteractivates links and buttons;Spaceactivates buttons, checkboxes; arrow keys operate radios, tabs, menus, listboxes, sliders per APG.Esccloses dialogs, menus, and popovers and returns focus to the trigger.- No keyboard trap (2.1.2): you can always leave a widget with
TaborEsc. - Hover-only content (tooltips, mega menus) is reachable by focus and dismissible without moving focus (1.4.13).
3. Focus
- Every focused element has a visible indicator (2.4.7). Check the code for
outline: nonewithout a replacement:grep -rn "outline: *none\|outline: *0" src. - Focused elements are not fully hidden by sticky headers, cookie banners, or chat
widgets (2.4.11 Focus Not Obscured, new in 2.2). Fix with
scroll-padding-top. - Opening a modal moves focus into it; closing returns focus to the opener. Content
behind is inert (
<dialog>withshowModal()does both natively). - Route changes in SPAs move focus to the new page’s
<h1>or main region and updatedocument.title.
4. Contrast
| Content | Minimum ratio (AA) |
|---|---|
| Body text under 24 px (or under 18.66 px bold) | 4.5:1 (1.4.3) |
| Large text ≥ 24 px, or ≥ 18.66 px bold | 3:1 |
| UI component boundaries, icons conveying meaning, focus indicators, chart lines | 3:1 against adjacent colors (1.4.11) |
| Disabled controls, pure decoration, logos | Exempt |
Check computed colors, including text over images/gradients (test the worst spot) and both light and dark themes. Use the bundled script for pairs pulled from tokens or devtools:
python3 scripts/contrast_check.py "#6b7280" "#ffffff"
python3 scripts/contrast_check.py --file pairs.txt --min 4.5 # lines: fg bg [label]
It exits non-zero when any pair falls below the threshold and composites colors with alpha over the background.
5. Semantics
- One
<h1>per page; headings do not skip levels for styling (1.3.1). - Landmarks:
<header>,<nav aria-label>,<main>(exactly one),<footer>. Multiple navs get distinct labels. - Every control has an accessible name that contains its visible label (2.5.3 Label in
Name). Icon buttons:
aria-labelor visually hidden text. - Images: meaningful
alt; decorativealt=""; complex charts get a text alternative. - Links describe their destination (“Download invoice PDF”, not “Click here”).
- Buttons do actions, links navigate.
<div onclick>is a finding (4.1.2). langon<html>and on passages in other languages (3.1.1, 3.1.2).- Status messages (cart updated, saved, search results count) use
role="status"oraria-live="polite"without moving focus (4.1.3).
6. Custom widgets
Compare each against the WAI-ARIA Authoring Practices pattern: dialog, disclosure,
accordion, tabs, menu button, combobox, listbox, tooltip, carousel. Prefer native
elements (<dialog>, <details>, <select>, popover attribute) over ARIA rebuilds.
Common failures:
role="menu"used for site navigation (it promises app-menu keyboard behavior).aria-expandednot toggled;aria-selectedon tabs not updated.- Combobox without
aria-activedescendantor arrow-key support. aria-hidden="true"on an element that contains focusable children.
7. Zoom, reflow, spacing, targets, motion
- 200% browser zoom: no loss of content or function (1.4.4).
- 320 CSS px wide viewport (1280 px at 400%): no horizontal scrolling except for data tables, maps, code (1.4.10).
- Apply text spacing overrides (line-height 1.5, paragraph spacing 2em, letter spacing 0.12em, word spacing 0.16em): no clipped or overlapping text (1.4.12).
- Pointer targets at least 24×24 CSS px or with enough spacing (2.5.8, new in 2.2).
- Drag-only interactions (sortable lists, sliders, maps) have a single-pointer alternative (2.5.7, new in 2.2).
- Auto-playing motion over 5 s can be paused (2.2.2); animations respect
prefers-reduced-motion.
8. Screen reader pass
Run the two or three critical flows (sign up, search → product → add to cart →
checkout) with VoiceOver (Cmd+F5) and NVDA:
- Navigate by headings and landmarks; the outline makes sense.
- Every control announces name, role, state (“Subscribe, checkbox, not checked”).
- Form errors are announced and associated with fields.
- Dynamic updates (toasts, live search results) are announced once, not repeatedly.
WCAG 2.2 notes
- New AA criteria vs. 2.1: 2.4.11 Focus Not Obscured (Minimum), 2.5.7 Dragging Movements, 2.5.8 Target Size (Minimum); new A: 3.2.6 Consistent Help, 3.3.7 Redundant Entry; new AA: 3.3.8 Accessible Authentication (no cognitive tests without alternatives; allow paste and password managers).
- 4.1.1 Parsing is removed in 2.2; do not report duplicate IDs under it (report under 1.3.1 or 4.1.2 if they break relationships).
For the full A/AA criterion list with a test method for each, read references/wcag-22-checklist.md when producing a conformance report or VPAT, or when a finding’s criterion is unclear.
Severity
| Severity | Definition | Example |
|---|---|---|
| Critical | Blocks a task for a group of users | Checkout button unreachable by keyboard; modal traps focus |
| Serious | Task possible but very hard; major info loss | Form errors not announced; 2.8:1 body text |
| Moderate | Friction or confusion | Heading levels skipped; vague link text |
| Minor | Polish | Redundant title attributes |
Report template
# Accessibility audit — <product>, <date>
**Standard:** WCAG 2.2 AA **Scope:** 7 templates, 3 flows **AT:** VoiceOver/Safari 18, NVDA 2025/Chrome
**Summary:** 4 critical, 9 serious, 12 moderate, 5 minor. Top risks: keyboard access to checkout, form error announcements.
## Findings
### A-01 — Critical — 2.1.1 Keyboard
**Where:** /checkout, "Place order" (`button.cta-submit`, a `<div>`)
**Impact:** Keyboard and switch users cannot complete purchase.
**Steps:** Tab through the page; the control never receives focus.
**Fix:** Use `<button type="submit">`; remove the click handler on the div.
**Verify:** Reachable by Tab, activates with Enter and Space; axe rule passes.
## Passed checks / not tested
- Reflow at 320 px: pass on all templates
- Not tested: PDF downloads, third-party chat widget (vendor responsibility, noted)
Verification of fixes
- Re-run the axe scan and the specific manual step from each finding.
- Add Playwright + axe checks for fixed templates so regressions fail CI.
- Never mark a finding fixed based on code review alone; reproduce the original steps.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/wcag-22-checklist.md
# WCAG 2.2 Level A and AA checklist with test methods
Read this when writing a conformance report or VPAT, or when mapping a finding to its
success criterion. "Auto" means axe/Lighthouse catch common failures; "Manual" means
they cannot judge it. Criteria marked (2.2) are new in WCAG 2.2.
## 1. Perceivable
| SC | Level | Requirement | How to test |
|---|---|---|---|
| 1.1.1 Non-text Content | A | Images, icons, controls have text alternatives; decorative ones are hidden | Auto for missing alt; Manual for alt quality and decorative choices |
| 1.2.1 Audio-only and Video-only (Prerecorded) | A | Transcript or audio description alternative | Manual |
| 1.2.2 Captions (Prerecorded) | A | Synchronized captions for video with audio | Manual: play media |
| 1.2.3 Audio Description or Media Alternative | A | Description or full text alternative | Manual |
| 1.2.4 Captions (Live) | AA | Live captions | Manual |
| 1.2.5 Audio Description (Prerecorded) | AA | Audio description for meaningful visuals | Manual |
| 1.3.1 Info and Relationships | A | Structure conveyed in markup: headings, lists, tables, labels, groups | Auto partial; Manual review of headings, table headers, fieldsets |
| 1.3.2 Meaningful Sequence | A | DOM order matches reading order | Manual: disable CSS or use screen reader linear read |
| 1.3.3 Sensory Characteristics | A | Instructions do not rely only on shape, position, sound | Manual: read instructions |
| 1.3.4 Orientation | AA | Works in portrait and landscape | Manual: rotate device/emulator |
| 1.3.5 Identify Input Purpose | AA | Correct `autocomplete` tokens on personal-data fields | Auto partial; Manual check tokens |
| 1.4.1 Use of Color | A | Color is not the only signal (links in text, errors, charts) | Manual; grayscale filter |
| 1.4.2 Audio Control | A | Auto-playing audio over 3 s can be paused/muted | Manual |
| 1.4.3 Contrast (Minimum) | AA | 4.5:1 text, 3:1 large text | Auto partial; script for overlays, images, states |
| 1.4.4 Resize Text | AA | 200% zoom without loss | Manual: browser zoom |
| 1.4.5 Images of Text | AA | Real text instead of text in images | Manual |
| 1.4.10 Reflow | AA | No 2-D scroll at 320 CSS px width | Manual: 320 px viewport |
| 1.4.11 Non-text Contrast | AA | 3:1 for UI boundaries, states, focus, meaningful graphics | Manual + script |
| 1.4.12 Text Spacing | AA | No loss with increased spacing overrides | Manual: apply spacing bookmarklet/stylesheet |
| 1.4.13 Content on Hover or Focus | AA | Dismissible (Esc), hoverable, persistent | Manual |
## 2. Operable
| SC | Level | Requirement | How to test |
|---|---|---|---|
| 2.1.1 Keyboard | A | All functionality operable by keyboard | Manual walkthrough |
| 2.1.2 No Keyboard Trap | A | Focus can always move away | Manual |
| 2.1.4 Character Key Shortcuts | A | Single-key shortcuts can be turned off or remapped, or only active on focus | Manual |
| 2.2.1 Timing Adjustable | A | Time limits can be extended/disabled (session timeouts warn) | Manual |
| 2.2.2 Pause, Stop, Hide | A | Moving/auto-updating content over 5 s can be paused | Manual |
| 2.3.1 Three Flashes or Below Threshold | A | No content flashes more than 3 times per second | Manual |
| 2.4.1 Bypass Blocks | A | Skip link or landmarks | Manual + Auto partial |
| 2.4.2 Page Titled | A | Unique, descriptive `<title>` (also after SPA navigation) | Auto for presence; Manual for quality |
| 2.4.3 Focus Order | A | Logical focus order | Manual |
| 2.4.4 Link Purpose (In Context) | A | Link text plus context identifies destination | Manual; screen reader links list |
| 2.4.5 Multiple Ways | AA | More than one way to find pages (nav, search, sitemap) | Manual |
| 2.4.6 Headings and Labels | AA | Headings and labels describe content | Manual |
| 2.4.7 Focus Visible | AA | Visible focus indicator | Manual |
| 2.4.11 Focus Not Obscured (Minimum) (2.2) | AA | Focused element not entirely hidden by author content | Manual: tab with sticky headers/banners open |
| 2.5.1 Pointer Gestures | A | Multipoint/path gestures have single-pointer alternative | Manual |
| 2.5.2 Pointer Cancellation | A | Actions fire on up-event or can be aborted | Manual |
| 2.5.3 Label in Name | A | Accessible name contains visible label text | Auto partial; Manual |
| 2.5.4 Motion Actuation | A | Shake/tilt features have UI alternative and can be disabled | Manual |
| 2.5.7 Dragging Movements (2.2) | AA | Drag operations have single-pointer alternative | Manual |
| 2.5.8 Target Size (Minimum) (2.2) | AA | Targets ≥ 24×24 CSS px or spaced; inline links exempt | Manual: measure in devtools |
## 3. Understandable
| SC | Level | Requirement | How to test |
|---|---|---|---|
| 3.1.1 Language of Page | A | `lang` on `<html>` | Auto |
| 3.1.2 Language of Parts | AA | `lang` on foreign-language passages | Manual |
| 3.2.1 On Focus | A | Focus does not trigger context change | Manual |
| 3.2.2 On Input | A | Changing a control does not unexpectedly change context (auto-submit selects) | Manual |
| 3.2.3 Consistent Navigation | AA | Repeated nav in same order across pages | Manual |
| 3.2.4 Consistent Identification | AA | Same function, same label across pages | Manual |
| 3.2.6 Consistent Help (2.2) | A | Help mechanisms in same relative place across pages | Manual |
| 3.3.1 Error Identification | A | Errors identified and described in text | Manual: submit invalid data |
| 3.3.2 Labels or Instructions | A | Inputs have labels/instructions | Auto partial; Manual |
| 3.3.3 Error Suggestion | AA | Suggest how to fix when known | Manual |
| 3.3.4 Error Prevention (Legal, Financial, Data) | AA | Reversible, checked, or confirmed submissions | Manual |
| 3.3.7 Redundant Entry (2.2) | A | Previously entered info is auto-populated or selectable | Manual: walk multi-step flows |
| 3.3.8 Accessible Authentication (Minimum) (2.2) | AA | No cognitive function test without alternative; paste and password managers allowed | Manual: try paste in login, check CAPTCHA alternatives |
## 4. Robust
| SC | Level | Requirement | How to test |
|---|---|---|---|
| 4.1.2 Name, Role, Value | A | Custom controls expose name, role, state; updates announced | Auto partial; Manual with screen reader |
| 4.1.3 Status Messages | AA | Status updates announced without focus change | Manual with screen reader |
4.1.1 Parsing was removed in WCAG 2.2 (treated as always satisfied).
## Conformance report row format
```markdown
| Criterion | Conformance | Remarks |
|---|---|---|
| 1.4.3 Contrast (Minimum) | Partially Supports | Placeholder text in search (2.85:1) and disabled-looking secondary buttons (3.9:1) fail; see A-04, A-07 |
| 2.4.11 Focus Not Obscured (Minimum) | Supports | Sticky header uses scroll-padding; verified on all templates |
```
Use the VPAT terms: Supports, Partially Supports, Does Not Support, Not Applicable.
scripts/contrast_check.py
#!/usr/bin/env python3
"""WCAG 2.x contrast ratio checker (stdlib only).
Usage:
python3 contrast_check.py FG BG [--min 4.5]
python3 contrast_check.py --file pairs.txt [--min 4.5]
Colors: #rgb, #rrggbb, #rrggbbaa, rgb(r, g, b), rgba(r, g, b, a).
A foreground with alpha is composited over the background before measuring.
pairs.txt: one pair per line, "FG BG [label...]"; blank lines and lines starting with "//" or "# " are ignored.
Exit code 1 if any pair is below --min, 2 on bad input.
"""
import argparse
import re
import sys
def parse_color(s: str):
s = s.strip().lower()
m = re.fullmatch(r"#([0-9a-f]{3,8})", s)
if m:
h = m.group(1)
if len(h) in (3, 4):
h = "".join(c * 2 for c in h)
if len(h) not in (6, 8):
raise ValueError(f"bad hex color: {s}")
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
a = int(h[6:8], 16) / 255 if len(h) == 8 else 1.0
return r, g, b, a
m = re.fullmatch(r"rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)", s)
if m:
r, g, b = (float(m.group(i)) for i in (1, 2, 3))
a = m.group(4)
if a is None:
alpha = 1.0
elif a.endswith("%"):
alpha = float(a[:-1]) / 100
else:
alpha = float(a)
return r, g, b, alpha
raise ValueError(f"unsupported color: {s} (use hex or rgb/rgba)")
def composite(fg, bg):
r, g, b, a = fg
br, bgc, bb, _ = bg
return (r * a + br * (1 - a), g * a + bgc * (1 - a), b * a + bb * (1 - a))
def luminance(rgb):
def chan(c):
c = c / 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def ratio(fg_s: str, bg_s: str) -> float:
fg, bg = parse_color(fg_s), parse_color(bg_s)
if bg[3] < 1:
raise ValueError(f"background must be opaque: {bg_s}")
l1, l2 = luminance(composite(fg, bg)), luminance(bg[:3])
hi, lo = max(l1, l2), min(l1, l2)
return (hi + 0.05) / (lo + 0.05)
def verdicts(r: float) -> str:
def mark(ok):
return "pass" if ok else "FAIL"
return (f"AA text {mark(r >= 4.5)} | AA large/UI {mark(r >= 3)} | "
f"AAA text {mark(r >= 7)} | AAA large {mark(r >= 4.5)}")
def main() -> int:
ap = argparse.ArgumentParser(description="WCAG contrast checker")
ap.add_argument("fg", nargs="?")
ap.add_argument("bg", nargs="?")
ap.add_argument("--file", help="file with 'FG BG [label]' per line")
ap.add_argument("--min", type=float, default=4.5, help="required ratio (default 4.5)")
args = ap.parse_args()
pairs = []
if args.file:
with open(args.file, encoding="utf-8") as fh:
for n, line in enumerate(fh, 1):
line = line.strip()
# comments: lines starting with "//" or "# " (a bare "#abc" is a color)
if not line or line.startswith("//") or line == "#" or line.startswith("# "):
continue
parts = re.findall(r"rgba?\([^)]*\)|\S+", line)
if len(parts) < 2:
print(f"line {n}: need FG and BG", file=sys.stderr)
return 2
pairs.append((parts[0], parts[1], " ".join(parts[2:]) or f"line {n}"))
elif args.fg and args.bg:
pairs.append((args.fg, args.bg, ""))
else:
ap.print_usage(sys.stderr)
return 2
failures = 0
for fg, bg, label in pairs:
try:
r = ratio(fg, bg)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
status = "OK " if r >= args.min else "LOW "
failures += r < args.min
tag = f" [{label}]" if label else ""
print(f"{status}{r:5.2f}:1 {fg} on {bg}{tag} ({verdicts(r)})")
print(f"\n{len(pairs)} pair(s), {failures} below {args.min}:1")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.