WCAG 2.1 AA requirements, ARIA roles and states, keyboard navigation, screen reader support, and accessible component patterns. Use when auditing accessibility or building accessible components.
From frontend-devnpx claudepluginhub bailejl/dev-plugins --plugin frontend-devThis skill uses the workspace's default tool permissions.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Migrates code, prompts, and API calls from Claude Sonnet 4.0/4.5 or Opus 4.1 to Opus 4.5, updating model strings on Anthropic, AWS, GCP, Azure platforms.
Facilitates interactive brainstorming sessions using diverse creative techniques and ideation methods. Activates when users say 'help me brainstorm' or 'help me ideate'.
Reference guide for WCAG 2.1 AA requirements, ARIA, keyboard navigation, screen reader support, and accessible component patterns. Use this knowledge when auditing accessibility, scaffolding components, or reviewing frontend code.
WCAG is organized into 4 principles (POUR): Perceivable, Operable, Understandable, Robust.
Users must be able to perceive the information presented.
| Criterion | ID | Requirement |
|---|---|---|
| Non-text Content | 1.1.1 | All non-text content has a text alternative (alt text, labels, captions) |
| Captions (Prerecorded) | 1.2.2 | Prerecorded audio in video has captions |
| Audio Description | 1.2.5 | Prerecorded video has audio description |
| Info and Relationships | 1.3.1 | Information and structure are programmatically determinable (semantic HTML, ARIA) |
| Meaningful Sequence | 1.3.2 | Reading/navigation order is logical and intuitive |
| Sensory Characteristics | 1.3.3 | Instructions don't rely solely on shape, size, position, or sound |
| Orientation | 1.3.4 | Content doesn't restrict to a single display orientation |
| Input Purpose | 1.3.5 | Input fields that collect user info have programmatic purpose (autocomplete) |
| Use of Color | 1.4.1 | Color is not the only visual means of conveying information |
| Contrast (Minimum) | 1.4.3 | Text has ≥4.5:1 contrast (normal) or ≥3:1 (large: 18px+ or 14px+ bold) |
| Resize Text | 1.4.4 | Text can resize up to 200% without loss of content or function |
| Images of Text | 1.4.5 | Use real text instead of images of text (exceptions: logos) |
| Reflow | 1.4.10 | Content reflows at 320px width / 256px height without horizontal scroll |
| Non-text Contrast | 1.4.11 | UI components and graphics have ≥3:1 contrast against adjacent colors |
| Text Spacing | 1.4.12 | Content adapts to: line-height 1.5x, paragraph spacing 2x, letter spacing 0.12em, word spacing 0.16em |
| Content on Hover/Focus | 1.4.13 | Hover/focus-triggered content is dismissible, hoverable, and persistent |
Users must be able to operate the interface.
| Criterion | ID | Requirement |
|---|---|---|
| Keyboard | 2.1.1 | All functionality is available from a keyboard |
| No Keyboard Trap | 2.1.2 | Keyboard focus can always be moved away from any component |
| Character Key Shortcuts | 2.1.4 | Single-character key shortcuts can be turned off or remapped |
| Timing Adjustable | 2.2.1 | Time limits can be extended or turned off |
| Pause, Stop, Hide | 2.2.2 | Auto-moving/updating content can be paused, stopped, or hidden |
| Three Flashes | 2.3.1 | Content doesn't flash more than 3 times per second |
| Skip Navigation | 2.4.1 | Skip-to-content link is available to bypass repeated blocks |
| Page Titled | 2.4.2 | Pages have descriptive titles |
| Focus Order | 2.4.3 | Focus order preserves meaning and operability |
| Link Purpose | 2.4.4 | Link purpose is determinable from link text (or context) |
| Multiple Ways | 2.4.5 | Multiple ways to locate content (nav, search, sitemap) |
| Headings and Labels | 2.4.6 | Headings and labels describe the topic or purpose |
| Focus Visible | 2.4.7 | Keyboard focus indicator is visible |
| Pointer Gestures | 2.5.1 | Multi-point/path gestures have single-pointer alternatives |
| Pointer Cancellation | 2.5.2 | Actions triggered on up-event, not down-event (allows cancellation) |
| Label in Name | 2.5.3 | Accessible name contains the visible label text |
| Motion Actuation | 2.5.4 | Device motion actions have UI alternatives and can be disabled |
Users must be able to understand the information and operation.
| Criterion | ID | Requirement |
|---|---|---|
| Language of Page | 3.1.1 | Default language is programmatically set (lang attribute) |
| Language of Parts | 3.1.2 | Language changes within content are marked |
| On Focus | 3.2.1 | Focus doesn't trigger context changes |
| On Input | 3.2.2 | Input doesn't trigger unexpected context changes |
| Consistent Navigation | 3.2.3 | Navigation mechanisms are consistent across pages |
| Consistent Identification | 3.2.4 | Components with the same function are identified consistently |
| Error Identification | 3.3.1 | Errors are identified and described in text |
| Labels or Instructions | 3.3.2 | Labels or instructions are provided for user input |
| Error Suggestion | 3.3.3 | Error messages suggest corrections when possible |
| Error Prevention | 3.3.4 | Submissions involving legal/financial/data are reversible, verified, or confirmed |
Content must be robust enough for assistive technologies.
| Criterion | ID | Requirement |
|---|---|---|
| Parsing | 4.1.1 | HTML is well-formed (unique IDs, proper nesting) |
| Name, Role, Value | 4.1.2 | All UI components have accessible name, role, and state |
| Status Messages | 4.1.3 | Status messages are programmatically announced without focus change |
First rule of ARIA: Don't use ARIA if you can use a native HTML element.
<!-- BAD: ARIA button -->
<div role="button" tabindex="0" onclick="submit()">Submit</div>
<!-- GOOD: Native button -->
<button onclick="submit()">Submit</button>
Native HTML elements have built-in keyboard handling, roles, and states. ARIA is for when no native element exists (custom widgets).
| Role | Use Case | Native Equivalent |
|---|---|---|
button | Clickable trigger | <button> |
link | Navigation | <a href> |
checkbox | Toggle option | <input type="checkbox"> |
radio | Exclusive option | <input type="radio"> |
tab, tablist, tabpanel | Tab interface | None |
dialog | Modal/dialog | <dialog> |
alert | Important message | None |
status | Status update | None |
menu, menuitem | Action menu | None |
tree, treeitem | Tree view | None |
grid, row, gridcell | Data grid | <table> |
region | Generic landmark | <section> with label |
navigation | Nav landmark | <nav> |
banner | Page header | <header> (top-level) |
main | Main content | <main> |
contentinfo | Page footer | <footer> (top-level) |
complementary | Supporting content | <aside> |
search | Search area | <search> (HTML5.2+) |
| Attribute | Purpose | Values |
|---|---|---|
aria-label | Accessible name (no visible label) | String |
aria-labelledby | Accessible name (references visible element) | ID(s) |
aria-describedby | Additional description | ID(s) |
aria-expanded | Disclosure state | true / false |
aria-pressed | Toggle button state | true / false / mixed |
aria-checked | Checkbox/radio state | true / false / mixed |
aria-selected | Selection state (tabs, options) | true / false |
aria-hidden | Hidden from assistive tech | true / false |
aria-disabled | Disabled state | true / false |
aria-required | Required field | true / false |
aria-invalid | Validation state | true / false / grammar / spelling |
aria-errormessage | Error message reference | ID |
aria-live | Live region update behavior | polite / assertive / off |
aria-busy | Content being updated | true / false |
aria-current | Current item indicator | page / step / location / date / true |
aria-haspopup | Element opens a popup | true / menu / listbox / tree / grid / dialog |
aria-controls | Element controls another | ID |
aria-owns | Parent-child relationship override | ID(s) |
aria-modal | Marks a dialog as modal | true |
aria-roledescription | Custom role description | String |
| Pattern | Implementation |
|---|---|
| Tab: Move to next focusable element | Native browser behavior — ensure tabindex is correct |
| Shift+Tab: Move to previous | Native — same as above |
| Enter/Space: Activate | Buttons handle both natively. Custom elements: handle keydown |
| Escape: Close/dismiss | Handle in modal, dropdown, popover, tooltip handlers |
| Arrow keys: Navigate within widget | Tabs, menus, radio groups, grids, trees |
| Home/End: First/last item | Lists, menus, tabs, sliders |
For composite widgets (tabs, menus, radio groups), use roving tabindex:
// Only the active item is in the tab order
items.map((item, i) => (
<button
key={item.id}
role="tab"
tabIndex={i === activeIndex ? 0 : -1}
aria-selected={i === activeIndex}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') setActiveIndex((activeIndex + 1) % items.length);
if (e.key === 'ArrowLeft') setActiveIndex((activeIndex - 1 + items.length) % items.length);
if (e.key === 'Home') setActiveIndex(0);
if (e.key === 'End') setActiveIndex(items.length - 1);
}}
>
{item.label}
</button>
));
function useFocusTrap(containerRef: RefObject<HTMLElement>) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = container.querySelectorAll<HTMLElement>(focusableSelector);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [containerRef]);
}
Screen readers traverse the accessibility tree (derived from the DOM). They announce:
aria-label, alt, <label>)aria-describedbyaria-labelledby (references another element's text)aria-label (direct string)<label for>, <caption>, <legend>, <figcaption>)<button>, <a>, heading elements)title attribute (last resort — unreliable across browsers)placeholder (not a label — don't rely on it)Announce dynamic content updates without moving focus.
<!-- Polite: announces when user is idle (default for most updates) -->
<div aria-live="polite">3 items in cart</div>
<!-- Assertive: announces immediately, interrupting current speech -->
<div role="alert">Error: Payment failed. Please try again.</div>
<!-- Status: implicit aria-live="polite" -->
<div role="status">Search returned 42 results</div>
Guidelines:
aria-live="polite" for most updates (cart count, search results, saved confirmation)role="alert" or aria-live="assertive" only for urgent messages (errors, warnings)aria-atomic="true" when the entire region should be re-read on any change| Content Type | Minimum Ratio | WCAG Criterion |
|---|---|---|
| Normal text (< 18px or < 14px bold) | 4.5:1 | 1.4.3 AA |
| Large text (≥ 18px or ≥ 14px bold) | 3:1 | 1.4.3 AA |
| UI components (borders, icons) | 3:1 | 1.4.11 AA |
| Focus indicators | 3:1 | 1.4.11 AA |
| Disabled elements | No requirement | — |
| Decorative elements | No requirement | — |
Tools for contrast checking:
#999 on #fff = 2.85:1 (FAILS). Use #767676 for 4.5:1.#0000ee on #333 = 3.36:1 (fails for normal text).#ff0000 on white = 4:1 (fails). Use #c00 or #d32f2f for better contrast.<div>
<label for="email">
Email address
<span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
required
aria-required="true"
autocomplete="email"
aria-describedby="email-hint"
/>
<p id="email-hint">We'll never share your email.</p>
</div>
<div>
<label for="password">Password</label>
<input
id="password"
type="password"
aria-invalid="true"
aria-errormessage="password-error"
aria-describedby="password-requirements"
/>
<p id="password-requirements">Must be at least 8 characters.</p>
<p id="password-error" role="alert">
Password is too short. Please enter at least 8 characters.
</p>
</div>
<fieldset>
<legend>Shipping method</legend>
<label>
<input type="radio" name="shipping" value="standard" />
Standard (5-7 days)
</label>
<label>
<input type="radio" name="shipping" value="express" />
Express (1-2 days)
</label>
</fieldset>
<dialog
ref={dialogRef}
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
aria-modal="true"
>
<h2 id="dialog-title">Confirm deletion</h2>
<p id="dialog-description">This action cannot be undone.</p>
<button onClick={onConfirm}>Delete</button>
<button onClick={onClose}>Cancel</button>
</dialog>
Requirements:
inert attribute or aria-hidden)<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>
</div>
<div role="tabpanel" id="panel-general" aria-labelledby="tab-general">
General settings content
</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>
Security settings content
</div>
Requirements:
<div>
<h3>
<button
aria-expanded={isOpen}
aria-controls="section-1-content"
>
Section 1
</button>
</h3>
<div id="section-1-content" role="region" aria-labelledby="section-1-heading" hidden={!isOpen}>
Section content
</div>
</div>
<button aria-describedby="tooltip-1">
Settings
</button>
<div id="tooltip-1" role="tooltip" hidden={!showTooltip}>
Manage your account settings
</div>
Requirements:
<div>
<label for="search">Search users</label>
<input
id="search"
role="combobox"
aria-expanded={isOpen}
aria-controls="search-listbox"
aria-activedescendant={activeOptionId}
aria-autocomplete="list"
/>
<ul id="search-listbox" role="listbox" aria-label="Search results">
{results.map(result => (
<li
key={result.id}
id={`option-${result.id}`}
role="option"
aria-selected={result.id === activeId}
>
{result.name}
</li>
))}
</ul>
</div>
Requirements:
aria-activedescendantaria-live)jest-axe or vitest-axeimport { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});