a11y-aria-patterns — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited a11y-aria-patterns (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Skill type: Reference. This is teaching/pattern content — it explains the WAI-ARIA APG keyboard contracts and ARIA state rules for custom widgets. It is not a scanner. For static review of existing ARIA usage, use/a11y-review. For runtime verification of ARIA validity against resolved roles, use thea11y-testingskill to set up real axe.
ARIA (Accessible Rich Internet Applications) extends HTML semantics for custom, dynamic UI components. It supplements — never replaces — native HTML.
The ARIA First Principles:
<button> before role="button". Use <select> before a custom combobox.ARIA adds three things to elements in the accessibility tree:
role="dialog", role="tab")aria-label, aria-required)aria-expanded, aria-checked, aria-invalid)Every interactive element needs an accessible name. The browser computes it in this priority order:
aria-labelledby (references another element's text — highest priority)aria-label (inline text string)<label for="id">)title attribute (last resort — inconsistent screen reader support)<!-- Priority 1: aria-labelledby -->
<h2 id="section-title">Billing Address</h2>
<section aria-labelledby="section-title">...</section>
<!-- Priority 2: aria-label (when no visible text exists) -->
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<!-- Priority 3: Associated label -->
<label for="card-number">Card Number</label>
<input id="card-number" type="text">
<!-- Priority 4: Button text -->
<button>Submit Order</button>aria-describedby provides supplementary information — announced after the name, often as a separate speech unit:
<label for="password">Password</label>
<input
id="password"
type="password"
aria-describedby="password-hint password-error"
>
<p id="password-hint">Must be at least 8 characters.</p>
<p id="password-error" aria-live="polite"></p>Multiple IDs in aria-describedby are read in order.
<!-- Background content inert when modal open -->
<main inert aria-hidden="true">...</main>
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<h2 id="dialog-title">Confirm Deletion</h2>
<p id="dialog-desc">This action cannot be undone.</p>
<button type="button">Cancel</button>
<button type="button">Delete</button>
</div>Required behavior:
Tab and Shift+Tab within the dialogEscape: Close dialog, return focus to the trigger element`role="alertdialog"` — for urgent dialogs requiring immediate response (e.g., unsaved changes warning). Screen readers interrupt current speech to announce it.
<div role="tablist" aria-label="Settings Sections">
<button role="tab" aria-selected="true" aria-controls="panel-general" id="tab-general">General</button>
<button role="tab" aria-selected="false" aria-controls="panel-security" id="tab-security" tabindex="-1">Security</button>
<button role="tab" aria-selected="false" aria-controls="panel-billing" id="tab-billing" tabindex="-1">Billing</button>
</div>
<div role="tabpanel" id="panel-general" aria-labelledby="tab-general">...</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>...</div>
<div role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" hidden>...</div>Keyboard behavior (roving tabindex):
| Key | Action |
|---|---|
Tab | Enter tablist on active tab; Tab again exits to tabpanel |
→ / ← | Move to next/previous tab (wraps); auto-activates tab |
Home | Move to first tab |
End | Move to last tab |
Enter / Space | Activate tab (manual activation variant) |
Roving tabindex: Only the active tab has tabindex="0". All others have tabindex="-1". JavaScript updates both on navigation.
<h3>
<button
type="button"
aria-expanded="false"
aria-controls="faq-1-content"
id="faq-1-btn"
>
What is your return policy?
</button>
</h3>
<div id="faq-1-content" role="region" aria-labelledby="faq-1-btn" hidden>
<p>You may return items within 30 days...</p>
</div>Rules:
aria-expanded="true/false" on the trigger button — must be JS-driven, never hardcodedaria-controls links button to its panel (ID reference)hidden / aria-hidden="true" on collapsed panels<label for="country-input">Country</label>
<div role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="country-listbox">
<input
id="country-input"
type="text"
autocomplete="off"
aria-autocomplete="list"
aria-controls="country-listbox"
aria-activedescendant=""
>
</div>
<ul id="country-listbox" role="listbox" aria-label="Countries">
<li role="option" id="opt-us" aria-selected="false">United States</li>
<li role="option" id="opt-ca" aria-selected="false">Canada</li>
</ul>Keyboard behavior:
| Key | Action |
|---|---|
↓ | Open list, move to first option |
↑ / ↓ | Navigate options |
Enter | Select focused option |
Escape | Close list |
Home / End | Jump to first/last option |
Update aria-activedescendant on the input to the ID of the currently highlighted option.
Use role="menu" for application menus (File, Edit, View), NOT for navigation. Use <nav> + <ul> for site navigation.
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="actions-menu"
id="actions-btn"
>
Actions ▾
</button>
<ul role="menu" id="actions-menu" aria-labelledby="actions-btn">
<li role="menuitem"><button type="button">Edit</button></li>
<li role="menuitem"><button type="button">Duplicate</button></li>
<li role="separator"></li>
<li role="menuitem" aria-disabled="true"><button type="button" disabled>Archive</button></li>
<li role="menuitem"><button type="button">Delete</button></li>
</ul>Keyboard behavior:
| Key | Action |
|---|---|
↓ / ↑ | Navigate items (wraps) |
Enter / Space | Activate item |
Escape | Close menu, return focus to trigger |
Home / End | Jump to first/last item |
A–Z | Type-ahead selection |
Live regions announce dynamic content changes to screen readers without requiring a focus move.
aria-live Values| Value | Behavior | Use For |
|---|---|---|
polite | Waits for user to finish current action | Status updates, form feedback, search results loading |
assertive | Interrupts immediately | Critical errors, urgent alerts |
off (default) | No announcement | Most content |
<!-- Status messages (polite) -->
<div role="status" aria-live="polite" aria-atomic="true">
<!-- Inject text here when status changes -->
</div>
<!-- Error alerts (assertive) -->
<div role="alert" aria-live="assertive">
<!-- Inject critical error message here -->
</div>role="alert" = implicit aria-live="assertive" + aria-atomic="true"role="status" = implicit aria-live="polite" + aria-atomic="true"role="log" = implicit aria-live="polite" for sequential messages (chat, audit trail)role="timer" = implicit aria-live="off" — only announce value when directly focusedToast notifications:
<!-- Single persistent container in DOM; inject/clear text dynamically -->
<div id="toast-region" role="status" aria-live="polite" aria-atomic="true" class="sr-only">
<!-- JS sets textContent here; empty string clears it -->
</div>Loading state:
<div aria-busy="true" aria-live="polite">
<p>Loading search results...</p>
</div>
<!-- When complete, set aria-busy="false" and replace content -->Form error summary:
<div role="alert" aria-live="assertive" id="error-summary">
<!-- Inject on submit: "3 errors found. Please correct: Email is required, ..." -->
</div>Critical rule: The live region container must exist in the DOM before you inject content into it. Adding both the container and the content simultaneously often fails to trigger announcements.
aria-hidden="true" removes an element and all its descendants from the accessibility tree.
<!-- Decorative icon inside a labeled button -->
<button type="button" aria-label="Close">
<svg aria-hidden="true" focusable="false">...</svg>
</button>
<!-- Decorative image -->
<img src="hero-bg.jpg" alt="" aria-hidden="true">
<!-- Modal: hide background content -->
<main aria-hidden="true" inert>...</main><!-- ❌ CRITICAL: aria-hidden on a focusable element -->
<button aria-hidden="true">Submit</button>
<!-- Screen reader can't see it; keyboard user can still Tab to it — disorienting -->
<!-- ❌ CRITICAL: aria-hidden ancestor contains focusable descendants -->
<div aria-hidden="true">
<button>Click me</button> <!-- Keyboard reaches it; screen reader can't -->
</div>
<!-- ❌ Never hide content that provides information without a visible alternative -->
<nav aria-hidden="true">
<a href="/">Home</a> <!-- Navigation is now invisible to AT users -->
</nav>Rule: If aria-hidden="true" is on an element, that element and all its children must be non-focusable. Use inert attribute as the modern alternative (removes focus AND hides from AT simultaneously).
// ❌ WRONG: String "true"/"false" — technically works, but brittle
<button aria-expanded="true">Toggle</button>
// ✅ CORRECT: JSX boolean expression — stays in sync with state
const [isOpen, setIsOpen] = useState(false);
<button aria-expanded={isOpen}>Toggle</button>// ✅ CORRECT: Radix passes all ARIA props through asChild
<Dialog.Trigger asChild>
<Button variant="outline"> {/* Button must spread props + forwardRef */}
Open
</Button>
</Dialog.Trigger>
// ❌ WRONG: Custom component that swallows props
const Button = ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
// Missing: {...props} spread — loses aria-haspopup, aria-expanded, etc.
);Used in: tablist, toolbar, menu, listbox, tree, radiogroup, grid
const [focusedIndex, setFocusedIndex] = useState(0);
const itemRefs = useRef([]);
function handleKeyDown(e, index) {
const count = items.length;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
const next = (index + 1) % count;
setFocusedIndex(next);
itemRefs.current[next]?.focus();
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
const prev = (index - 1 + count) % count;
setFocusedIndex(prev);
itemRefs.current[prev]?.focus();
}
if (e.key === 'Home') { setFocusedIndex(0); itemRefs.current[0]?.focus(); }
if (e.key === 'End') { const last = count-1; setFocusedIndex(last); itemRefs.current[last]?.focus(); }
}
return (
<div role="tablist">
{items.map((item, i) => (
<button
key={item.id}
role="tab"
tabIndex={i === focusedIndex ? 0 : -1}
aria-selected={i === focusedIndex}
ref={el => itemRefs.current[i] = el}
onKeyDown={e => handleKeyDown(e, i)}
onClick={() => setFocusedIndex(i)}
>
{item.label}
</button>
))}
</div>
);~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.