accessibility-aria-review
Paste into Claude Code, Claude.ai, the desktop app, or the browser extension — ⌘/Ctrl+V. ✓ Copied. Switch to Claude and paste — ⌘/Ctrl+V.
Accessibility ARIA Review
Perform a targeted ARIA audit on a component, page, or design-system pattern. Goes deeper than automated tools on role correctness, state lifecycle, keyboard contract, and focus management — areas where incorrect ARIA is actively worse than no ARIA.
Usage
/accessibility-aria-review <path or URL> [--framework react|astro|nextjs|vanilla] [--focus landmarks|widgets|live-regions|keyboard|all]
Examples:
/accessibility-aria-review src/components/Modal.tsx --framework react --focus keyboard/accessibility-aria-review src/components/ --focus widgets— audit all components in directory/accessibility-aria-review https://localhost:3000 --focus landmarks
First Rule of ARIA
No ARIA is better than bad ARIA.
Before adding any ARIA attribute, verify whether a native HTML element already provides the semantics. If it does, use the native element. ARIA should only be used to close a gap that HTML cannot fill.
| Do this | Instead of |
|---|---|
<button> |
<div role="button"> |
<nav> |
<div role="navigation"> |
<ul><li> |
<div role="list"><div role="listitem"> |
<h1>–<h6> |
<div role="heading" aria-level="1"> |
Phase 1 — Landmark Structure (SC 2.4.1, 1.3.1)
Required landmarks
Every page must have exactly the following landmark structure (or it fails SC 2.4.1):
<header> / role="banner" — site-wide header (once per page)
<nav> / role="navigation" — primary navigation (label if multiple)
<main> / role="main" — primary content (once per page)
<footer> / role="contentinfo" — site-wide footer (once per page)
Landmark audit checklist
- Is there exactly one
<main>element? - Is there exactly one
role="banner"/<header>at page level? - Are all
<nav>elements uniquely labeled witharia-labeloraria-labelledby? - Does all meaningful content sit inside a landmark (not outside main/aside/nav)?
- Are
<section>elements that contain meaningful sub-regions given anaria-label? (Without a label,<section>has no landmark role.) - Is
<aside>/role="complementary"used only for supplementary content (not primary content)? - Are decorative containers (
<div>,<span>) free of spurious landmark roles?
Duplicate landmark handling
When multiple instances of the same landmark exist (two <nav>, multiple <section>), each must
have a unique, descriptive aria-label:
<!-- Correct -->
<nav aria-label="Primary">…</nav>
<nav aria-label="Breadcrumb">…</nav>
<!-- Incorrect — AT announces both as "navigation" with no distinction -->
<nav>…</nav>
<nav>…</nav>
Phase 2 — Widget Role Correctness (SC 4.1.2)
Role-to-interaction contract mapping
For each interactive widget found, verify the full ARIA contract:
| Widget pattern | Role | Required attributes | Keyboard contract |
|---|---|---|---|
| Accordion | button on trigger |
aria-expanded, aria-controls pointing to panel |
Enter/Space toggle; no roving tabindex needed |
| Tab interface | tablist, tab, tabpanel |
aria-selected, aria-controls on tab, aria-labelledby on panel |
Arrow keys between tabs; Tab to panel content |
| Modal dialog | dialog |
aria-modal="true", aria-labelledby pointing to heading |
Focus trap; Escape closes; return focus to trigger on close |
| Menu / dropdown | menu + menuitem |
aria-haspopup, aria-expanded on trigger |
Arrow keys navigate items; Tab closes; Escape closes |
| Combobox | combobox |
aria-expanded, aria-controls, aria-autocomplete |
Arrow keys select; Enter confirms; Escape closes |
| Tooltip | tooltip |
aria-describedby on trigger |
Shows on focus AND hover; not interactive itself |
| Slider | slider |
aria-valuenow, aria-valuemin, aria-valuemax |
Arrow keys adjust value |
| Alert / toast | alert |
— | Auto-announced on insertion; no focus movement needed |
| Progress indicator | progressbar |
aria-valuenow, aria-valuemin, aria-valuemax (or aria-valuetext for indeterminate) |
Not focusable unless interactive |
| Tree view | tree, treeitem |
aria-expanded (for parents), aria-selected |
Arrow keys navigate; Right expands; Left collapses |
Common widget errors to check
Accordion:
<!-- Incorrect: div is not keyboard operable -->
<div onclick="toggle()" aria-expanded="false">Section title</div>
<!-- Correct -->
<button aria-expanded="false" aria-controls="panel-1">Section title</button>
<div id="panel-1" role="region" aria-labelledby="trigger-1">…</div>
Tab interface — keyboard trap check:
// Tabs must use arrow keys for navigation, not Tab key
tabList.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') focusNextTab();
if (e.key === 'ArrowLeft') focusPreviousTab();
if (e.key === 'Home') focusFirstTab();
if (e.key === 'End') focusLastTab();
});
Modal — focus trap and return:
// On open: move focus to first focusable element inside dialog
// On close: return focus to the element that triggered the modal
const trigger = document.activeElement;
modal.showModal(); // or manually trap
modal.addEventListener('close', () => trigger.focus());
Phase 3 — Live Region Design (SC 4.1.3)
Live regions announce dynamic content changes to screen readers without requiring focus movement.
Live region types and when to use them
| Type | Equivalent | Interrupts? | Use for |
|---|---|---|---|
role="alert" |
aria-live="assertive" |
Yes | Errors, urgent warnings |
role="status" |
aria-live="polite" |
No | Success messages, save confirmations, progress updates |
aria-live="polite" |
— | No | Dynamic content updates (search results count, filter feedback) |
aria-live="assertive" |
— | Yes | Only for genuinely time-critical updates |
role="log" |
aria-live="polite", aria-relevant="additions" |
No | Chat logs, activity feeds |
role="timer" |
— | No | Countdown timers (announce sparingly) |
Live region audit checklist
- Is
aria-live="assertive"used sparingly? (Overuse is disruptive to screen reader users.) - Are live regions pre-existing in the DOM before content is injected? (Adding the element and content simultaneously may not trigger announcement.)
- Is the live region container empty on load and populated dynamically — or does it contain initial static content that will confuse AT?
- For form validation errors: are they in a
role="alert"container AND linked viaaria-describedbyto the relevant field? - For search result counts: is the live region
polite, notassertive?
CMS-specific live region patterns
Sitecore JSS / React:
// Correct pattern: stable container, dynamic content
const [message, setMessage] = React.useState('');
return (
<div role="status" aria-live="polite" aria-atomic="true">
{message}
</div>
);
Optimizely / .NET Razor:
<!-- Render the container server-side; populate client-side -->
<div id="status-region" role="status" aria-live="polite"></div>
<script>
document.getElementById('status-region').textContent = 'Form saved successfully.';
</script>
Phase 4 — Keyboard Interaction Audit (SC 2.1.1, 2.1.2)
Keyboard audit checklist
- Tab order — Tab through the entire interactive surface. Does focus move in a logical, left-to-right, top-to-bottom sequence? (See also SC 2.4.3.)
- No keyboard trap — Tab out of every component. Focus should always be escapable (SC 2.1.2). Exception: modal dialogs must trap focus while open.
- Custom interactive elements — Any
<div>or<span>acting as a button/link? Verify it has:tabindex="0", a keyboard role, and handles bothclickandkeydown(Enter/Space). - Focus visible — Is there a visible focus ring on every interactive element? (SC 2.4.7) Fail if
outline: noneis applied without a custom indicator of equal or greater visibility. - Roving tabindex pattern — Used for widget internals (tabs, menu items, radio groups). Only one item in the group is in the tab order at a time; arrow keys navigate within.
- Skip link — Is "Skip to main content" the first focusable element? Does it actually move focus to
<main>? - Escape key — Do all overlays, dropdowns, tooltips, and modals close on Escape?
Framework-specific keyboard pitfalls
React:
onClickhandlers withoutonKeyDownmiss keyboard users.useEffectfocus management: call.focus()after the DOM update, not before.- Portals (for modals): ensure the portal root is correctly positioned in the DOM for focus trap.
Next.js (App Router):
- Client-side navigation does NOT move focus automatically. Use
router.eventsor the<FocusAfterNavigation>pattern to return focus to the page<h1>after route change.
Astro islands:
- Each island is independently hydrated. Ensure island components that include interactive triggers manage focus internally and do not rely on parent page state.
Phase 5 — State Attribute Hygiene (SC 4.1.2)
Audit every use of these state attributes for correctness:
| Attribute | Valid values | Common error |
|---|---|---|
aria-expanded |
"true" / "false" |
Set on the trigger, not the panel; never omit when pattern requires it |
aria-selected |
"true" / "false" |
Required on tab and option roles; "false" must be explicit |
aria-checked |
"true" / "false" / "mixed" |
"mixed" only for tri-state checkboxes |
aria-pressed |
"true" / "false" / "mixed" |
Toggle buttons only |
aria-hidden |
"true" / absent |
Never on focusable elements; always hides from AT |
aria-disabled |
"true" / absent |
Does NOT prevent keyboard focus (unlike disabled); still announce to AT |
aria-controls |
ID reference | Must point to an existing element in the DOM |
aria-labelledby |
ID reference(s) | All referenced IDs must exist |
aria-describedby |
ID reference(s) | All referenced IDs must exist |
Red flags to report
aria-hidden="true"on a focusable element — keyboard users reach it, but AT users get silence.aria-expandednot toggled — component JS doesn't update the attribute on open/close.aria-controlspointing to a non-existent ID — broken relationship.aria-liveon the wrong element — e.g. on a container that gets replaced, not updated.
Phase 6 — Report Output
Produce a Markdown findings list. Each finding follows the Finding Record schema in
accessibility-wcag-scan/templates.md.
Include a summary section specific to ARIA:
## ARIA Review Summary
| Category | Pass | Fail | Not Applicable |
|---|---|---|---|
| Landmark structure | | | |
| Widget role correctness | | | |
| Live regions | | | |
| Keyboard interaction | | | |
| State attribute hygiene | | | |
Note any components where removing ARIA is the correct fix (role added unnecessarily, or conflicting with native HTML semantics).
Reference
- WAI-ARIA Authoring Practices Guide (APG): https://www.w3.org/WAI/ARIA/apg/
- ARIA 1.2 specification: https://www.w3.org/TR/wai-aria-1.2/
- MDN ARIA reference: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA
Technical details
- Plugin
- accessibility-auditor
- Author
- Dakota Smith
- Skill name
accessibility-aria-review- Maturity
- experimental
- Security review
- No security review
- Owners
-
- design
- engineering
- security
- Argument hint
<component path or URL> [--framework react|astro|nextjs|vanilla] [--focus landmarks|widgets|live-regions|keyboard|all]- Allowed tools
-
ReadWebFetch(domain:localhost)WebFetch(domain:*.localhost)WebFetch(domain:www.w3.org)WebFetch(domain:developer.mozilla.org)Bash(cat *)Bash(grep *)Bash(ls *)Bash(find *)WriteEdit
- Source
plugins/accessibility-auditor/skills/accessibility-aria-review/SKILL.md
Edit this skill on GitHub — opens GitHub's web editor. No git knowledge needed.