Audits HTML/UI code against core accessibility and UX rules: semantics, ARIA, focus, contrast, keyboard paths, forms.
What it does
Plenty of interfaces work perfectly for someone with a mouse and good eyesight, and quietly do not work at all for anyone else. A menu built from the wrong kind of element cannot be reached by keyboard. A button with only an icon announces as nothing. Grey text on a white background is unreadable outdoors, and an error message that appears silently is invisible to someone using a screen reader.
This runs a structured pass over the interface in a deliberate order: the underlying elements first, then whether everything can be reached and used with a keyboard, whether it is visible where you are, whether every control has a name, then forms, contrast, zoom, motion and anything announced after the page loads. Each problem comes back with how serious it is, which rule it breaks, and the corrected code where the fix is short.
Nothing gets rewritten behind your back. You get a report, ordered worst first, plus a plain statement of what was and was not checked.
You end up with findings ranked worst first, each with the rule it breaks and the corrected markup.
When to reach for it
Use it before shipping anything people will use, after a redesign that moved markup or colors around, and whenever someone asks whether a page is accessible, works with a keyboard, or would pass an audit.
Skip it for work with no user interface, and know its limit: reviewing code finds many problems but cannot confirm focus order or announcements. Those need the page actually running, and the report says so when that was not possible.
---
name: web-a11y-audit
description: Audits interface code against WCAG 2.2 AA and core interaction rules - element semantics, keyboard operation, visible focus, accessible names, forms, contrast, motion and live regions - and reports each violation with a severity, the rule it breaks, and the corrected markup. Report-only. Use before shipping user-facing UI, or when asked whether something is accessible or works with a keyboard.
---
# Web Accessibility Audit
You find the failures that stop an interface working for people using a keyboard, a screen reader, magnification, or a phone in bright sun. You report them with severity and a fix; you do not silently rewrite the code, because the author needs to see what was broken and why.
The bar is WCAG 2.2 AA unless someone names a different one. (WCAG is the standard for web accessibility; AA is the level most organizations are held to, legally and contractually.)
## Workflow
Check in this order. Each stage assumes the one before it passed, because a fix at an early stage often deletes findings at a later one.
1. **Semantics.** Is the right element doing the job? `button` for actions, `a[href]` for navigation, real headings in order with one `h1`, lists as lists, `nav` / `main` / `header` / `footer` landmarks. A `div` with a click handler is the single most common failure and it causes several others downstream.
2. **Keyboard operation.** Everything interactive is reachable with Tab and Shift+Tab and operable with Enter, Space, and arrow keys where the pattern expects them. No positive `tabindex`. Focus is never trapped except deliberately, inside a modal dialog.
3. **Visible focus.** Every interactive element shows where focus is. `outline: none` with no replacement is a failure. Focus moves sensibly after an action: into a dialog when it opens, back to the trigger when it closes, to the error summary after a failed submit.
4. **Names and roles.** Every control has an accessible name. Icon-only buttons need one. Images need `alt`, empty `alt=""` when decorative. Do not add ARIA that repeats what the element already says.
5. **Forms.** Every field has a real associated label. Errors are tied to their field and announced. Required and invalid states are conveyed programmatically, not by color alone. Autocomplete is set on personal fields.
6. **Contrast and color.** Text at 4.5:1, large text and interface components at 3:1. Nothing carries meaning by color alone.
7. **Reflow and target size.** Usable at 200% zoom and at a 320px viewport with no horizontal scrolling or clipping. Interactive targets big enough to hit on a phone, with spacing between them.
8. **Motion and media.** `prefers-reduced-motion` respected. No motion that plays automatically without a way to stop it. Captions on video.
9. **Live regions.** Anything that appears asynchronously - a toast, a validation message, a loading state, a filtered result count - reaches assistive technology through a live region or a role that implies one.
```text
Audit pass checklist
[ ] Right element for the job, headings in order, landmarks present
[ ] Tab reaches every control, and arrow keys work where the pattern needs them
[ ] Focus is visible everywhere and moves correctly on open and close
[ ] Every control and image has an accessible name
[ ] Every field has a label; errors are tied to the field
[ ] Text contrast measured, not eyeballed
[ ] Works at 200% zoom and 320px wide
[ ] Reduced motion respected
[ ] Async updates are announced
```
## Report format
Report only. Do not rewrite the file. For each finding:
- **Severity**: Blocker, Serious, or Minor.
- **Location**: file and element.
- **Rule**: the principle it breaks, in one line.
- **Fix**: the concrete change, with corrected markup when it is short.
Group by severity, Blockers first, and end with a one-line verdict. If nothing is wrong in an area, say so rather than padding the report.
**Severity is decided by what the user cannot do:**
| Severity | Test | Examples |
| --- | --- | --- |
| Blocker | Someone cannot complete the task at all | Control unreachable by keyboard, no accessible name, focus trapped, form unusable without a mouse |
| Serious | Task is possible but the experience is degraded or error-prone | Contrast below the threshold, illogical focus order, errors not announced, headings out of order |
| Minor | Rough edge with a workaround | Redundant ARIA, a decorative image with descriptive alt text, a slightly small target |
## Known widget patterns
Custom widgets have expected keyboard and role behavior. Name the pattern rather than inventing markup, and check the real one against it:
| Widget | Expected |
| --- | --- |
| Modal dialog | `role="dialog"` `aria-modal="true"`, labelled, focus moves in on open, trapped while open, Escape closes, focus returns to the trigger |
| Tabs | Tab enters the tab list, arrow keys move between tabs, `aria-selected`, panel associated with its tab |
| Disclosure | A real `button` with `aria-expanded`, controlling the region it toggles |
| Combobox | Text input plus a listbox, arrows to move, Enter to choose, Escape to close, `aria-activedescendant` or managed focus |
| Menu button | `aria-haspopup`, `aria-expanded`, arrow keys within the menu, Escape closes and returns focus |
| Accordion | Each header is a button with `aria-expanded`, controlling its panel |
If a widget does not match its pattern, that is the finding. Do not invent a bespoke arrangement of ARIA to paper over it.
## How to verify quickly
- Tab from the top of the page to the bottom. Every stop should be visible, and nothing interactive should be skipped.
- Put the mouse away. Open the menu, fill the form, submit it, dismiss the dialog, using only keys.
- Zoom to 200%. Nothing clips, overlaps, or disappears.
- Narrow to 320px. Nothing scrolls sideways.
- Look at it in greyscale. Any meaning that vanishes was carried by color alone.
- Read only the headings, top to bottom. They should outline the page on their own.
- Read each link's text with the page covered. "Read more" tells you nothing about where it goes.
Automated checkers are worth running and will not finish the job: they reliably catch contrast, missing alt text and duplicate ids, and they cannot tell whether focus order makes sense or whether a name describes the control. The walkthrough above is what finds the rest.
## Example
**Finding, Blocker.** `components/SiteNav.jsx`, the menu items.
```jsx
<nav>
<div className="link" onClick={() => go('/pricing')}>Pricing</div>
</nav>
```
**Rule.** Navigation must be real links. A `div` with a click handler is not focusable, is not announced as a link, cannot be opened in a new tab, and does not respond to Enter.
**Fix.** Use the element that already does this:
```jsx
<nav aria-label="Main">
<a href="/pricing">Pricing</a>
</nav>
```
One change removes four separate findings: keyboard reachability, the accessible role, the missing name, and the focus ring, which the `a` element gets for free. This is why semantics is checked first.
## Rules
- Prefer the native element. The first rule of ARIA is not to use ARIA when HTML already does the job, and most ARIA findings are a symptom of the wrong element.
- Missing accessible name, unreachable control, or trapped focus is always a Blocker. Do not soften it because the fix is easy.
- Do not certify what you have not walked through. If you have not gone tab by tab, say the audit was static and name what it could not cover.
- Report the pattern by name for known widgets rather than improvising markup.
- A hint shown inside an empty field is not a label. It disappears the moment someone types.
- Do not pad the report. A short accurate audit is trusted; a long one full of hedged findings gets ignored.
## Edge cases
- **Only a screenshot is available.** You can assess contrast, target size, focus indication if visible, and color-only meaning. Say clearly that semantics, names and keyboard operation could not be checked.
- **A third-party widget is the problem.** Report it, name the version and the specific failure, and note whether it can be configured around. Do not patch a vendor's shadow DOM as a first move.
- **A design system component is at fault.** Report it once, at the component, and list the pages that inherit it rather than filing the same finding many times.
- **The framework generates the markup.** Audit the rendered output, not the source. The framework's abstraction is not what the browser hands to assistive technology.
- **Contrast fails only in one state**, such as a disabled control or a hover color. Report the state. Disabled controls have a lower bar but still need to be readable if their text matters.
- **An animation is the product**, such as a chart transition. Respect reduced motion by making the change instant, not by removing the feature.
- **Someone asks for a compliance statement.** Report findings and coverage. An audit finds failures; it cannot prove their absence.
## Files
- `references/checklist.md` - the full pass, area by area, with what to check, how to check it, and the severity each failure carries. Work through it during the audit.
- `references/examples.md` - three complete audits with their reports: a navigation and modal, a checkout form, and a custom select. Read to see how findings are worded and severities assigned.
references/checklist.md
# The audit pass
Work through the areas in order. Each entry names what to check, how to check it, and the severity a failure carries.
## Contents
- [1. Semantics and structure](#1-semantics-and-structure)
- [2. Keyboard operation](#2-keyboard-operation)
- [3. Focus](#3-focus)
- [4. Names, roles and images](#4-names-roles-and-images)
- [5. Forms](#5-forms)
- [6. Color and contrast](#6-color-and-contrast)
- [7. Reflow, zoom and target size](#7-reflow-zoom-and-target-size)
- [8. Motion and media](#8-motion-and-media)
- [9. Announcements](#9-announcements)
- [Coverage statement](#coverage-statement)
---
## 1. Semantics and structure
| Check | Failure | Severity |
| --- | --- | --- |
| Actions use `button`, navigation uses `a[href]` | A `div` or `span` with a click handler | Blocker |
| Exactly one `h1`, headings descend without skipping | `h2` followed by `h4`, or a styled `div` used as a heading | Serious |
| Landmarks present: `header`, `nav`, `main`, `footer` | No `main`, so skip-to-content has no target | Serious |
| Lists marked up as lists | A list of items built from sibling `div`s | Minor |
| Tables are data tables with `th` and scope | A layout table, or a data table with no header cells | Serious |
| Page has a unique, descriptive `title` | Every route shares one title | Serious |
| `html` has a correct `lang` | Missing or wrong language | Serious |
Fix this layer first. Correct elements bring focusability, roles, names and keyboard behavior with them, which deletes findings further down.
## 2. Keyboard operation
| Check | How | Severity if broken |
| --- | --- | --- |
| Every control reachable by Tab | Tab from the address bar to the end of the page | Blocker |
| Tab order follows the visual order | Watch the focus ring travel | Serious |
| Enter and Space activate buttons; Enter follows links | Try both on each control type | Blocker |
| Arrow keys work where the pattern expects them | Tabs, menus, listboxes, radio groups | Serious |
| No positive `tabindex` | Search the source for `tabindex="1"` and up | Serious |
| Focus is not trapped outside a modal | Tab past the widget and confirm you escape | Blocker |
| Escape closes menus and dialogs | Press it in each | Serious |
| A skip link exists on pages with long navigation | Tab once from the top | Minor |
## 3. Focus
| Check | Failure | Severity |
| --- | --- | --- |
| Visible indicator on every focusable element | `outline: none` with nothing replacing it | Blocker |
| Indicator is visible against its background | A dark ring on a dark button | Serious |
| Focus moves into a dialog when it opens | Focus stays behind the overlay | Blocker |
| Focus returns to the trigger when a dialog closes | Focus resets to the top of the page | Serious |
| Focus moves to the error summary after a failed submit | The user is left at the bottom with no idea what happened | Serious |
| Focus is never moved without user action | An input steals focus on page load | Serious |
| The focused element is never hidden behind sticky UI | A sticky header covers the focused field | Serious |
## 4. Names, roles and images
| Check | Failure | Severity |
| --- | --- | --- |
| Every control has an accessible name | An icon-only button with no label | Blocker |
| The visible label is part of the accessible name | Button reads "Save" but is named "Submit form" | Serious |
| Informative images have meaningful `alt` | `alt="image"`, `alt="chart"`, or missing | Serious |
| Decorative images have `alt=""` | A spacer image announced as "graphic" | Minor |
| Link text makes sense alone | Four links all reading "read more" | Serious |
| ARIA does not contradict the element | `role="button"` on an `a[href]` that navigates | Serious |
| No ARIA that duplicates native semantics | `role="navigation"` on a `nav` | Minor |
| `aria-hidden` never hides a focusable element | A hidden but tabbable control | Blocker |
## 5. Forms
| Check | Failure | Severity |
| --- | --- | --- |
| Every field has a `label` tied to it by `for` and `id` | A styled text node next to an input | Blocker |
| A hint inside the field is not the only label | The label disappears when typing starts | Blocker |
| Errors are tied to the field with `aria-describedby` | An error message the field never references | Serious |
| Errors are announced when they appear | Silent validation | Serious |
| The error says what to do | "Invalid input" with no explanation | Serious |
| Required is programmatic, not just an asterisk | A red star and nothing in the markup | Serious |
| Related controls grouped in `fieldset` with `legend` | A radio group with no group name | Serious |
| `autocomplete` set on name, email, address, phone | Autofill does not work | Minor |
| Instructions come before the field they describe | A rule stated after the input it governs | Minor |
## 6. Color and contrast
| Check | Threshold | Severity |
| --- | --- | --- |
| Body text against its background | 4.5:1 | Serious |
| Large text, 18.66px bold or 24px and up | 3:1 | Serious |
| Interface components and focus indicators | 3:1 | Serious |
| Meaning never carried by color alone | Add text, an icon, or a pattern | Serious |
| Link text distinguishable from body text without color | Underline, weight, or another cue | Serious |
| Text over an image is readable at every image | A scrim, a solid panel, or a different layout | Serious |
Measure with a contrast tool. Eyes are unreliable, especially on a bright monitor, and "looks fine to me" is exactly the judgment the threshold exists to replace.
## 7. Reflow, zoom and target size
| Check | How | Severity |
| --- | --- | --- |
| Usable at 200% browser zoom | Zoom and use the page | Serious |
| No horizontal scrolling at a 320px viewport | Narrow the window | Serious |
| Nothing clipped or overlapping when text is enlarged | Increase text size only, if the browser allows | Serious |
| Interactive targets are comfortably tappable, with spacing | Check on a real phone | Minor to Serious |
| Content is not locked to one orientation | Rotate the device | Serious |
## 8. Motion and media
| Check | Failure | Severity |
| --- | --- | --- |
| `prefers-reduced-motion` honored | Parallax and transitions play regardless | Serious |
| Motion over five seconds can be paused | An auto-advancing carousel with no control | Serious |
| Nothing flashes more than three times a second | A flashing alert | Blocker |
| Video has captions | None, or automatic captions never reviewed | Blocker |
| Audio has a transcript | None | Serious |
| Media does not autoplay with sound | It does | Serious |
## 9. Announcements
| Check | Failure | Severity |
| --- | --- | --- |
| Async status reaches a live region | A toast that appears silently | Serious |
| Result counts announced after filtering | "24 results" appears with no announcement | Serious |
| Loading states announced, and their end announced | A spinner nobody is told about | Serious |
| Live regions exist in the DOM before they fill | A region inserted with its message already inside is often missed | Serious |
| `aria-live="assertive"` reserved for genuine urgency | Every toast interrupting the user | Minor |
## Coverage statement
End every audit by saying what was actually checked. An audit that reviewed markup without running the page cannot speak to focus order, announcements, or contrast in real states, and saying so is what keeps the rest of the report trustworthy.
```text
Checked: markup, semantics, names, form associations, static contrast
Not checked: focus order in the live page, live-region announcements,
behavior at 200% zoom, third-party checkout iframe
Verdict: 3 Blockers, 5 Serious. Not ready to ship.
```
references/examples.md
# Worked audits
Three audits with the code that was reviewed and the report that went back.
## Contents
- [Audit 1: navigation and a modal](#audit-1-navigation-and-a-modal)
- [Audit 2: a checkout form](#audit-2-a-checkout-form)
- [Audit 3: a custom select](#audit-3-a-custom-select)
- [Patterns worth copying](#patterns-worth-copying)
---
## Audit 1: navigation and a modal
**The code:**
```jsx
<nav>
{links.map(l => (
<div key={l.href} className="nav-link" onClick={() => go(l.href)}>
{l.label}
</div>
))}
<div className="icon-btn" onClick={openSearch}>
<SearchIcon />
</div>
</nav>
{isOpen && (
<div className="overlay">
<div className="modal">
<h3>Search</h3>
<input className="search-input" />
<div className="close" onClick={close}>x</div>
</div>
</div>
)}
```
**The report:**
```text
File: components/SiteNav.jsx
Verdict: 4 Blockers, 2 Serious. Not shippable.
BLOCKERS
1. Navigation items are not links
Location: nav > div.nav-link
Rule: Navigation must use a[href] so items are focusable, announced as
links, and can be opened in a new tab.
Fix: <a href={l.href}>{l.label}</a>, and drop the click handler.
2. The search trigger has no accessible name
Location: div.icon-btn
Rule: Every control needs a name. An icon alone announces as nothing.
Fix: <button type="button" onClick={openSearch} aria-label="Search">
<SearchIcon aria-hidden="true" />
</button>
3. The dialog is not a dialog and does not manage focus
Location: div.modal
Rule: A modal needs role="dialog" aria-modal="true", a name, focus moved
in on open, focus trapped while open, and focus returned to the
trigger on close. None of that is present, so a keyboard user
opens the dialog and is still tabbing through the page behind it.
Fix: <div role="dialog" aria-modal="true" aria-labelledby="search-title">
<h2 id="search-title">Search</h2>
plus focus management on open and close.
4. The close control is not a button
Location: div.close
Rule: Not focusable, no role, no name. "x" is also not a name.
Fix: <button type="button" onClick={close} aria-label="Close search">
SERIOUS
5. Escape does not close the dialog
Rule: A modal dialog closes on Escape.
Fix: Key handler on the dialog calling close().
6. Heading level skips
Location: h3 inside the dialog, page has h1 then h3
Rule: Headings descend without skipping levels.
Fix: h2, which is also the dialog's accessible name via aria-labelledby.
```
**What is worth noticing.** Findings 1, 2 and 4 are all the same root cause: elements chosen for appearance rather than behavior. Fixing that one habit removes four findings, and the focus ring appears for free because native controls have one. Finding 3 is separate and would survive the other fixes.
---
## Audit 2: a checkout form
**The code:**
```html
<form>
<div class="field">
<span class="label">Email</span>
<input type="text" class="input">
<span class="error" id="err-1">Invalid</span>
</div>
<div class="field">
<span class="label">Card number *</span>
<input type="text" class="input" maxlength="19">
</div>
<div class="checkbox-row" onclick="toggleTerms()">
<span class="box"></span> I accept the terms
</div>
<button class="pay">Pay 49.00</button>
</form>
```
**The report:**
```text
File: checkout/PaymentForm.html
Verdict: 3 Blockers, 4 Serious. Not shippable.
BLOCKERS
1. No field has a real label
Location: span.label next to each input
Rule: A label must be associated with its control by for/id or by
wrapping it. A styled span is invisible to assistive technology,
and clicking it does not focus the field.
Fix: <label for="email">Email</label>
<input id="email" type="email" autocomplete="email">
2. The terms checkbox is not a checkbox
Location: div.checkbox-row
Rule: Not focusable, no checked state exposed, Space does nothing.
A keyboard user cannot agree to the terms, so they cannot pay.
Fix: <input type="checkbox" id="terms"> with a real label.
3. The error is not connected to its field
Location: span.error#err-1
Rule: An error must be tied to the field with aria-describedby and
announced when it appears. Today it is a red word next to an
input, invisible to anyone not looking at it.
Fix: <input id="email" aria-describedby="err-1" aria-invalid="true">
and render the error into a live region.
SERIOUS
4. "Invalid" does not say what to do
Fix: "Enter an email address in the form name@example.com."
5. Required is marked only with an asterisk
Fix: Add required to the input, and explain the asterisk in the form.
6. Wrong input types
Rule: type="text" for an email or a card number loses validation,
the right mobile keyboard, and autofill.
Fix: type="email" with autocomplete="email";
inputmode="numeric" autocomplete="cc-number" for the card.
7. Focus is not moved to the error on failed submit
Rule: After a failed submit, focus goes to an error summary or the
first bad field, or the user has no idea anything happened.
```
**The judgment call.** Finding 2 was rated Blocker rather than Serious because the checkbox gates the payment button. The same widget in a newsletter sign-up would be Serious. Severity follows what the user loses, not the shape of the defect.
---
## Audit 3: a custom select
**The code:** a `div` with `role="listbox"`, options as `div role="option"`, opened by a click on a trigger `div`, closed on outside click. `aria-selected` is set correctly on the chosen option, which is why it had passed an automated check.
**The report:**
```text
File: components/CountrySelect.jsx
Verdict: 2 Blockers, 3 Serious. Recommend replacing with a native select.
BLOCKERS
1. The trigger is not focusable
Rule: tabindex is absent from the trigger div, so the control cannot be
reached at all. Correct ARIA on an unreachable control is
decoration.
2. No keyboard interaction
Rule: The combobox pattern needs Up and Down to move through options,
Enter to choose, Escape to close and return focus, and typing to
jump to a match. None are implemented; the widget is mouse-only.
SERIOUS
3. Selection is not announced
Rule: Choosing a country updates the trigger text with no announcement,
so a screen reader user does not learn what was selected.
Fix: Manage aria-activedescendant, or move real focus between options.
4. No accessible name on the listbox
Fix: aria-labelledby pointing at the visible "Country" label.
5. The options list is not dismissed on Escape
Fix: Escape closes and returns focus to the trigger.
RECOMMENDATION
The requirement was custom styling of the closed state. A native select
supports that, and brings keyboard support, announcements, and the mobile
picker at no cost. Rebuilding this widget correctly means implementing the
full combobox pattern and maintaining it; the styling gained does not
justify it. Replacing with a native select closes all five findings.
```
**Why the recommendation is in the report.** The audit's job is to say what is broken, and here the honest answer is that the widget is the wrong solution to a styling problem. Listing five fixes without saying that would have led to a correct implementation of something nobody needed.
---
## Patterns worth copying
1. Fix semantics first and re-audit. Several findings usually disappear at once.
2. Say which rule is broken, not just that something is wrong. The rule is what stops it recurring.
3. Show the corrected markup when it is short. It removes an entire round of clarification.
4. Set severity by what the user cannot do, and say when context raised it.
5. Note when correct ARIA is sitting on a broken control. Automated checks pass those; users do not.
6. Recommend the native element when a custom widget is not earning its maintenance.
7. End with a coverage statement, so nobody reads the audit as a clean bill of health for what you did not check.
Install it
Claude Code (this project)
Unzip the download into .claude/skills/. The folder is already named right.
Claude Code (every project)
Unzip into ~/.claude/skills/ to use it everywhere.
claude.ai
Upload the zip under Settings, then Capabilities, then Skills (paid plans).
How to call it
Just describe the task. The description line inside the skill does the matching. Or call it directly:
/web-a11y-auditrun an accessibility check on this pagedoes this work with a keyboardis our site WCAG compliantfind the contrast and screen reader problemsreview this component for a11y before we ship
Works beyond Claude
Skills use the open Agent Skills format. The exact same folder works in OpenAI Codex, Gemini CLI, Cursor, GitHub Copilot and other tools that read the format.
Using a plain chat app? Paste SKILL.md into ChatGPT or Gemini as instructions. You lose auto-triggering, but the method still works.