Skills / A11y Audit

A11y Audit

Runs axe-core and a real keyboard pass, shows the agent what's broken, and keeps it fixing source until the page passes.

audit.mjs on examples/inaccessible-page.html: 4 critical, 12 serious, 4 moderate. Dashed boxes come from the keyboard pass.
audit.mjs on examples/inaccessible-page.html: 4 critical, 12 serious, 4 moderate. Dashed boxes come from the keyboard pass.

Examples

Each example is a real run of the skill on a fixture in examples/. Nothing here is mocked up.

Example 1

A marketing page with 19 planted accessibility bugs

YouAudit this landing page for accessibility and fix everything serious.

A SaaS landing page built the way generated UIs often are: clickable divs, outline:none, placeholder-only fields, a newsletter form that traps Tab, a hover-only menu, a cookie bar that hides focused controls. axe-core plus the keyboard pass flag 20 rules (4 critical, 12 serious) and rank them in a Fix first list. The fixed twin, with semantic HTML, visible focus and real labels, comes back with 0 issues across 29 focus stops.

Annotated screenshot of the Brightly landing page: solid orange boxes mark axe findings (image-alt, color-contrast, placeholder-label, select-name), dashed boxes mark keyboard-pass findings (kbd-unreachable nav items and feature cards, kbd-hover-only on Resources, kbd-focus-trap in the footer form), and magenta numbered badges show focus stops with no visible focus.
Before: solid boxes are axe findings, dashed boxes are keyboard-pass findings. Magenta badges are focus stops with no visible focus ring, and their numbers jump around because of a positive tabindex.
Annotated screenshot of the fixed Brightly page: no issue boxes, only blue focus-order badges on the nav, hero buttons, cards, labelled form fields and footer.
After: the fixed twin. No issue boxes, and every focus stop is blue, meaning a visible focus indicator was measured.
Result
4 critical · 12 serious · 4 moderate → 0 on the fixed twin
Command
node scripts/audit.mjs examples/inaccessible-page.html
Input
inaccessible-page.html accessible-page.html
## Fix first

Highest impact first, then by how many elements one fix touches (fix the shared component or token once).

1. `image-alt` (critical, 2 elements): Images must have alternative text
2. `select-name` (critical, 2 elements): Select element must have an accessible name
3. `button-name` (critical, 1 element): Buttons must have discernible text
4. `kbd-focus-trap` (critical, 1 element): Keyboard focus is trapped
5. `kbd-focus-not-visible` (serious, 12 elements): Focused element shows no visible focus indicator
6. `kbd-unreachable` (serious, 8 elements): Clickable element cannot be reached with the keyboard
7. `color-contrast` (serious, 5 elements): Elements must meet minimum color contrast ratio thresholds
8. `placeholder-label` (serious, 4 elements): Form field is labelled only by its placeholder

Example 2

A subscription signup form that only looks finished

YouCheck our checkout signup form for accessibility problems and fix them.

A coffee-subscription signup shown right after a failed submit. The audit flags 7 fields named only by a placeholder, a light-red error message and a grey password hint that fail contrast, two custom checkboxes with no focus ring, and a 'Continue to payment' div that Tab never reaches. contrast.mjs gives the nearest passing shade of each color. Two planted bugs are not machine-checkable: the postcode error shown only as a red border, and errors not linked to their fields with aria-describedby. Those are left for the human review step, and the fixed twin fixes all six and passes clean.

Annotated screenshot of the Oatfield Coffee signup form: every text field boxed as placeholder-label, the red error text and grey password hint boxed as color-contrast, the two custom checkboxes marked with magenta badges for no visible focus, and the Continue to payment bar dashed as kbd-unreachable.
Before: placeholder-only fields, two contrast failures, checkboxes with no focus ring (magenta badges 11 and 12), and a div submit button the keyboard can't reach.
Annotated screenshot of the fixed signup form: visible labels above every field, error messages with an icon in darker red under the email and postcode fields, a darker password hint, and 14 blue focus badges ending on a real Continue to payment button.
After: visible labels, errors as text and icon tied to the field, the colors contrast.mjs suggested (#7e7570 hint, #cf474c errors), a focus ring on the checkbox, and a real button as stop 14.
Result
4 serious rules on 12 elements → 0 on the fixed twin
Command
node scripts/audit.mjs examples/signup-form.html
Input
signup-form.html signup-form-fixed.html
$ node scripts/contrast.mjs "#a8a29e" "#ffffff"
#a8a29e on #ffffff  →  2.52:1
  FAIL  AA                     needs 4.5:1
  FAIL  AAA                    needs 7:1
  FAIL  UI / large-text (3:1)  needs 3:1

  Nearest fg for 4.5:1 (same hue; ΔE = OKLab distance, 0.02 ≈ just noticeable):
    #7e7570  4.50:1  darker  ΔE 0.147  oklch(56.9% 0.014 51.2)

Example 3

An app shell whose modals leak focus and ignore Escape

YouMake the inbox nav and the New ticket modal keyboard accessible.

A support-desk inbox with a hover-only Reports menu and two hand-rolled modals, audited with --state to open each one. In the New ticket modal, Tab walks through 12 inbox controls hidden behind the backdrop, reported as focus leaking out of the open modal (kbd-focus-outside-modal), and Escape does nothing. The Keyboard shortcuts modal closes on Escape but drops focus to <body> instead of its button. With a disclosure button for the menu and native <dialog> with showModal() for both modals, all three states pass.

Annotated screenshot of the Kitebird inbox with the New ticket modal open: focus badges 1 to 12 sit on the New ticket button, Bulk actions, filter chips and ticket links behind the dimmed backdrop, each dashed and labelled kbd-focus-outside-modal, before badges 13 to 19 reach the modal's own controls.
State 1 (after clicking New ticket): focus stays on the trigger, and the first 12 Tab stops are inbox controls behind the open modal's backdrop. The modal's own fields only come at stops 13 to 19.
Annotated screenshot of the fixed New ticket dialog: six blue focus badges, all inside the dialog, from Customer email to Create ticket.
Fixed: native <dialog> with showModal(). Focus starts on the autofocused email field, Tab stays inside, Escape closes the dialog and focus returns to New ticket.
Top bar of the Kitebird inbox with focus badges on each tab and the Reports tab boxed with a dashed kbd-hover-only label.
Base page: hovering Reports opens a 3-link submenu, but focusing it does not, so kbd-hover-only.
Result
2 serious + 2 moderate across 3 states → 0
Command
node scripts/audit.mjs examples/app-shell.html --state "#new-ticket" --state "#shortcuts-btn"
Input
app-shell.html app-shell-fixed.html
1. `kbd-focus-outside-modal` (serious, 12 elements, 1 page): Focus leaves the open modal for the page hidden behind it
2. `kbd-hover-only` (serious, 1 element, 1 page): Menu opens on mouse hover but not from the keyboard
3. `kbd-dialog-escape` (moderate, 1 element, 1 page): Escape does not close the open modal dialog (ARIA APG dialog pattern)
4. `kbd-dialog-focus-return` (moderate, 1 element, 1 page): Closing the dialog does not return focus to its trigger

## Keyboard: app-shell--state-1 (state: `#new-ticket`)
| 1 | `#new-ticket` | New ticket | yes · **behind open modal `#ticket-backdrop > div.modal`** |
| 2 | `div.head:nth-of-type(1) > button.btn.btn-ghost` | Bulk actions | yes · **behind open modal `#ticket-backdrop > div.modal`** |

  - After Escape closed the dialog, focus went to the page body instead of the trigger `#shortcuts-btn`.

About this skill

Generated UIs look fine and are often unusable with a keyboard or a screen reader: clickable <div>s, outline: none, placeholder-only inputs, grey-on-white text. Most accessibility skills just give the agent a list of guidelines. This one runs real tools, shows the agent what it found, and keeps it fixing source until the page passes.

What it does

  1. axe-core audit: injects axe-core 4.13 into the page with Playwright (lazy iframes loaded first) and runs the WCAG 2.0/2.1/2.2 A + AA and best-practice rules.
  2. Keyboard pass (things axe can't check): presses Tab through the page for real and records:
    • focus order (numbered on the screenshot), focus traps and which elements they make unreachable
    • controls with no visible focus indicator (screenshot before vs. after focus, pixel diff; also finds rings drawn on a card, a label, or a sibling)
    • focus removed by a script (onfocus="blur()"), and focus hidden under a sticky header or cookie bar (WCAG 2.2 SC 2.4.11)
    • clickable elements keyboard users can't reach (cursor:pointer, onclick, interactive roles, real click listeners)
    • menus that open on hover but not from the keyboard
    • <select>s that navigate as soon as they change (3.2.2; the navigation is blocked, nothing new loads)
    • with --state on a modal: does Escape close it, does focus return to the trigger, and does Tab leak out to the page behind it
    • focus landing on invisible elements, positive tabindex, focus jumping backwards on screen
  3. Report: report.md opens with a prioritized "Fix first" list, groups identical markup, gives each rule the WCAG criteria, a fix, a note for your stack when it detects one (Next/React, Vue/Nuxt, Svelte, Angular, Astro, Tailwind, MUI, Radix), and an rg command to find the element in source; report.json for tooling; an annotated screenshot per page, sliced into readable parts on long pages.
  4. Fix loop: the agent maps each finding to your components, fixes them semantic HTML first, re-runs until there are zero critical/serious issues, then tells you what still needs a human.
  5. Contrast helper: contrast.mjs gives the WCAG ratio (truncated, never rounded up), AA/AAA pass/fail, and the nearest passing color with the same hue.

Example prompts

  • "Audit my app for accessibility"
  • "Is this page WCAG AA compliant? http://localhost:3000/pricing"
  • "Fix the contrast issues on the landing page"
  • "Make the nav and modal keyboard accessible"
  • "Check the signup form with a screen reader in mind"
  • "Run an a11y check on dist/index.html and fail if anything serious is left"

Requirements

  • Node.js 18+
  • Run bash scripts/setup.sh once. It installs Playwright 1.63.0, axe-core 4.13.0, and Chromium (~150 MB). If Chromium can't be downloaded, an installed Chrome or Edge is used.
  • A running dev server or a local .html file. No network calls except loading the page under test, no telemetry.

Try it

bash scripts/setup.sh
node scripts/audit.mjs examples/inaccessible-page.html     # 19 planted bugs, all found (plain axe finds 10)
node scripts/audit.mjs examples/accessible-page.html       # same page, fixed: 0 issues
node scripts/contrast.mjs "#777" "#fff"

Examples

Three worked examples in examples/, each a real run on a fixture next to its fixed twin (manifest: examples/examples.json, output images in examples/output/):

  • Marketing page (inaccessible-page.html): 19 planted bugs → 4 critical · 12 serious · 4 moderate rules; the fixed twin scores 0.
  • Signup form (signup-form.html): placeholder-only fields, low-contrast error and hint text with contrast.mjs suggestions, custom checkboxes with no focus ring, a <div> submit button.
  • App shell with modals (app-shell.html, --state for each modal): focus escaping behind the backdrop, Escape ignored, focus not returned, a hover-only menu; native <dialog> fixes all three states.

Sample output

◆ Auditing file:///…/examples/inaccessible-page.html (1280×800)
◆   axe-core 4.13.0: 12 rule violation(s), 1 need review
◆   keyboard: 14 focus stops, TRAP at #news-name; 12 without visible focus; 8 clickable-but-unreachable
◆   issues by impact: 4 critical · 12 serious · 4 moderate · 0 minor
◆ Report → .knack/a11y-audit/report.md
◆ JSON   → .knack/a11y-audit/report.json
◆ Image  → .knack/a11y-audit/inaccessible-page.annotated.png
$ node scripts/contrast.mjs "#a3a8b5" "#fff"
#a3a8b5 on #ffffff  →  2.37:1
  FAIL  AA                     needs 4.5:1
  FAIL  AAA                    needs 7:1
  FAIL  UI / large-text (3:1)  needs 3:1

  Nearest fg for 4.5:1 (same hue; ΔE = OKLab distance, 0.02 ≈ just noticeable):
    #727684  4.52:1  darker  ΔE 0.164  oklch(56.7% 0.022 273.0)

report.md excerpt:

### `kbd-focus-obscured`: Focused element is entirely hidden behind a sticky or fixed element
3 element(s) · keyboard pass · WCAG 2.4.11 Focus Not Obscured (Minimum)
**How to fix:** Reserve room for the sticky/fixed layer: html { scroll-padding-top: …; scroll-padding-bottom: … } …
- `#news-name`
  - `<input id="news-name" placeholder="Name">`
  - When focused, this element is entirely covered by the fixed/sticky element `div.cookie:nth-of-type(2)`.
  - find in source: `rg -n -F "news-name"`

How well it works

Measured in October 2026 against public pages (re-run the same commands to reproduce):

  • Recall, W3C "Before and After" demo (BAD): on the four inaccessible pages, of the 59 issues W3C documents (page annotations plus the evaluation reports), plain axe-core 4.13 finds 18 and this skill finds 26. The extra 8 are behavior axe does not test: links that throw focus away (F55) and the "quick menu" that navigates on change (3.2.2). Most of what neither finds needs judgment (alt text quality, reading order, fake headings, lists and tables), which is exactly the part left for a human.
  • Precision, accessible sites: GOV.UK (3 pages), the GOV.UK Design System (10 pages, including custom checkboxes and radios), and W3C WAI (4 pages incl. an APG modal): no findings from the keyboard pass or placeholder check. Earlier versions raised false alarms here (custom checkboxes reported as invisible, an iframe "without focus", a back-to-top link "jumping"); those are fixed.
  • Contrast: ratios match the WebAIM checker on 13 of 13 pairs (to the digits WebAIM shows), alpha compositing matches axe-core, and every suggested color re-checked on WebAIM passes. Against an exhaustive search of all 16.7M sRGB colors, the suggestion is within 5% of the true nearest passing color in OKLab for chromatic colors; for grays it stays neutral (at most 0.002 ΔE further, far below what anyone can see).

Files

Path What
SKILL.md The workflow the agent follows
scripts/audit.mjs axe-core + keyboard pass + annotated screenshot + reports
scripts/contrast.mjs Contrast ratio and nearest passing color
references/fix-recipes.md Before/after fixes (HTML, JSX, Tailwind) for ~35 rules, including every keyboard-pass rule
references/manual-checks.md The checks no tool can do: screen reader, zoom/reflow, motion, captions
examples/ Three example pairs, each a fixture with planted issues and its fixed twin (marketing page, signup form, app shell with modals), plus examples.json (prompts, commands, results) and the annotated output images in output/

Honest limits

Automated testing finds only part of the barriers: in GDS's test page with 142 known barriers, the best tool found 40% and axe 29% (GDS audit); Deque reports 57% when counting by issue volume (Deque). This skill tests more behavior than axe alone, but it can't judge whether alt text or link text is meaningful, whether reading order makes sense, or whether captions are accurate, and a clean report is not a WCAG conformance claim. It Tabs forwards only (check Shift+Tab under sticky headers yourself), runs Chromium only, and does not measure focus visibility inside iframes. The agent ends every run with a short manual checklist for you, and it will never suggest an overlay widget as a fix.

License

MIT