| name | accessibility |
| description | Use when building or reviewing UI components for keyboard and screen reader compatibility, adding ARIA to custom widgets, auditing a page for WCAG AA conformance, or preparing for a formal accessibility review. |
Accessibility
Build UIs that work for everyone — keyboard users, screen reader users, users with motor impairments, low-vision users, and users in constrained environments.
When to Activate
- Building or reviewing UI components (forms, modals, menus, tables)
- Implementing keyboard navigation or focus management
- Auditing a page or component for WCAG conformance
- Adding ARIA attributes to custom interactive widgets
- Reviewing color palette or typography choices for contrast
- Writing E2E tests that must be accessible-first
- Preparing for an accessibility audit or compliance review
WCAG Conformance Levels
| Level | What it means | Typical target |
|---|
| A | Bare minimum — removes critical blockers | Never ship below this |
| AA | Standard legal/compliance baseline (most regulations) | Default target |
| AAA | Enhanced — not always achievable for all content | Aim where practical |
Most legal requirements (ADA, WCAG 2.1 AA, EN 301 549) map to AA.
Semantic HTML First
Use the right element before reaching for ARIA.
<div class="btn" onclick="submit()">Submit</div>
<div class="nav"><div class="nav-item">Home</div></div>
<div class="heading">Page Title</div>
<button type="submit">Submit</button>
<nav><a href="/">Home</a></nav>
<h1>Page Title</h1>
Landmark Regions
<header>
<nav>
<main>
<aside>
<footer>
<section aria-label="Featured articles">
Heading Hierarchy
<h1>Site Name</h1>
<h3>Section Title</h3>
<span class="h2-style">Visual heading, not semantic</span>
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
ARIA
Use ARIA only when native HTML cannot express the semantics.
Rule of thumb
1. Use native HTML element → <button>, <input>, <select>
2. Use native attribute → disabled, required, checked
3. Use ARIA role + state + property → last resort for custom widgets
Common Roles
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<div role="alertdialog" aria-labelledby="alert-title" aria-describedby="alert-desc">
<div role="tablist"><div role="tab" aria-selected="true"><div role="tabpanel">
<ul role="listbox"><li role="option" aria-selected="false">
<div role="banner">
<div role="main">
<div role="navigation">
States and Properties
<button aria-expanded="false" aria-controls="menu-id">Menu</button>
<ul id="menu-id" hidden>...</ul>
<input aria-required="true" aria-invalid="true" aria-describedby="error-id">
<span id="error-id" role="alert">Email is required</span>
<div aria-live="polite">
<div aria-live="assertive" role="alert">
<button aria-label="Close dialog">✕</button>
<input aria-labelledby="label-id hint-id">
<table aria-describedby="table-desc">
ARIA Anti-Patterns
<button role="button">
<ul role="list">
<a href="/" role="link">
<button role="presentation">Submit</button>
<button aria-label="Click here to proceed">Submit order</button>
Keyboard Navigation
Focus Order
<button tabindex="3">First visually</button>
<button tabindex="1">Second visually</button>
<div tabindex="0" role="button">Custom focusable</div>
<div hidden>
<div aria-hidden="true" tabindex="-1">
Keyboard Patterns by Widget
| Widget | Keys required |
|---|
| Button | Enter, Space |
| Link | Enter |
| Checkbox | Space to toggle |
| Radio group | Arrow keys within group, Tab to leave |
| Select / Listbox | Arrow keys, Enter, Escape |
| Modal dialog | Tab/Shift+Tab trapped inside, Escape closes |
| Menu | Arrow keys, Escape, Enter/Space to select |
| Tabs | Arrow keys between tabs, Tab into panel |
| Slider | Arrow keys, Home, End |
Focus Management
function openModal(modalEl: HTMLElement) {
const firstFocusable = modalEl.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
function trapFocus(modalEl: HTMLElement, event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusable = modalEl.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
last.focus(); event.preventDefault();
} else if (!event.shiftKey && document.activeElement === last) {
first.focus(); event.preventDefault();
}
}
function closeModal(triggerEl: HTMLElement) {
triggerEl.focus();
}
Color and Visual Design
Contrast Ratios (WCAG AA)
| Text | Minimum ratio | Enhanced (AAA) |
|---|
| Normal text (< 18pt / 14pt bold) | 4.5:1 | 7:1 |
| Large text (≥ 18pt / 14pt bold) | 3:1 | 4.5:1 |
| UI components, icons, graphical elements | 3:1 | — |
| Decorative content | No requirement | — |
color: #999;
color: #767676;
color: #595959;
color: #0066cc;
Don't Rely on Color Alone
<span class="status-dot green"></span>
<span class="status-dot green" aria-label="Active"></span>Active
<input class="error-border">
<input aria-invalid="true" aria-describedby="err">
<span id="err">⚠ Email is required</span>
Motion and Animation
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Forms
<input type="email" placeholder="Enter email">
<label for="email">Email address</label>
<input id="email" type="email" autocomplete="email"
aria-required="true" aria-describedby="email-hint">
<span id="email-hint">We'll only use this to send your receipt.</span>
<fieldset>
<legend>Shipping address</legend>
<label for="street">Street</label>
<input id="street" type="text" autocomplete="street-address">
</fieldset>
<label for="phone">Phone number</label>
<input id="phone" type="tel" aria-invalid="true" aria-describedby="phone-error">
<span id="phone-error" role="alert">Enter a valid 10-digit phone number</span>
Images and Media
<img src="chart.png" alt="Bar chart showing Q4 revenue up 23% vs Q3">
<img src="divider.png" alt="">
<button aria-label="Delete item"><svg aria-hidden="true">...</svg></button>
<figure>
<img src="org-chart.png" alt="Organisation chart — see table below for text version">
<figcaption></figcaption>
</figure>
<video>
<track kind="captions" src="captions-en.vtt" srclang="en" label="English" default>
<track kind="descriptions" src="descriptions.vtt" srclang="en" label="Audio descriptions">
</video>
Testing
Manual checklist (do for every component)
- Tab through the page — every interactive element reachable?
- Nothing requires a mouse — all actions completable by keyboard?
- Focus indicator always visible?
- Test with screen reader (NVDA/Firefox on Windows, VoiceOver/Safari on Mac)
Automated tools
npx axe http://localhost:3000 --reporter cli
npx pa11y http://localhost:3000
npm install @axe-core/playwright
import { checkA11y, injectAxe } from "axe-playwright";
test("home page has no accessibility violations", async ({ page }) => {
await page.goto("/");
await injectAxe(page);
await checkA11y(page, null, {
detailedReport: true,
detailedReportOptions: { html: true },
});
});
Contrast check
npx contrast-ratio "#595959" "#ffffff"
Red Flags
aria-label on every element — ARIA overrides native semantics; use semantic HTML first and add ARIA only when native elements can't express the required role or state
role="button" on a <div> or <span> — custom button roles require manually implementing keyboard behavior; use <button> and get focus, Enter, and Space for free
alt="" on informational images — empty alt hides the image from screen readers entirely; empty alt is only correct for purely decorative images that convey no content
placeholder as the only label — placeholder text disappears on focus and has insufficient contrast ratio; every input must have an associated visible <label> element
outline: none without a visible replacement — removing the default focus ring makes keyboard navigation invisible; always provide a visible focus indicator that meets 3:1 contrast
- Automated scan as the complete accessibility test — axe/pa11y catches ~30% of WCAG issues; focus order, reading order, and screen reader announcements require manual verification
- Modal that doesn't trap focus — focus that escapes an open modal to background content disorients screen reader users; implement a focus trap and return focus to the trigger element on close
Checklist
See also: solution-testing, coding-standards