Accessibility
Accessibility patterns for WCAG 2.2 compliance, keyboard focus management, React Aria component patterns, cognitive inclusion, native HTML-first philosophy, and user preference honoring. Use when implementing screen reader support, keyboard navigation, ARIA patterns, focus traps, accessible component libraries, reduced motion, or cognitive accessibility.
Auto-activated — this skill loads automatically when Claude detects matching context.
Accessibility
Comprehensive patterns for building accessible web applications: WCAG 2.2 AA compliance, keyboard focus management, React Aria component patterns, native HTML-first philosophy, cognitive inclusion, and user preference honoring. Each category has individual rule files in rules/ loaded on-demand.
Quick Reference
| Category | Rules | Impact | When to Use |
|---|---|---|---|
| WCAG Compliance | 3 | CRITICAL | Color contrast, semantic HTML, automated testing |
| POUR Exit Criteria | 1 | CRITICAL | Falsifiable pass/fail thresholds for each WCAG 2.2 AA criterion |
| Static Anti-Patterns | 1 | HIGH | Grep-able patterns detectable without a browser |
| Focus Management | 3 | HIGH | Focus traps, focus restoration, keyboard navigation |
| React Aria | 3 | HIGH | Accessible components, form hooks, overlay patterns |
| Modern Web Accessibility | 3 | CRITICAL/HIGH | Native HTML first, cognitive inclusion, user preferences |
Total: 14 rules across 6 categories
Quick Start
// Semantic HTML with ARIA
<main>
<article>
<header><h1>Page Title</h1></header>
<section aria-labelledby="features-heading">
<h2 id="features-heading">Features</h2>
</section>
</article>
</main>// Focus trap with React Aria
import { FocusScope } from 'react-aria';
<FocusScope contain restoreFocus autoFocus>
<div role="dialog" aria-modal="true">
{children}
</div>
</FocusScope>WCAG Compliance
WCAG 2.2 AA implementation for inclusive, legally compliant web applications.
| Rule | File | Key Pattern |
|---|---|---|
| Color Contrast | $\{CLAUDE_SKILL_DIR\}/rules/wcag-color-contrast.md | 4.5:1 text, 3:1 UI components, focus indicators |
| Semantic HTML | $\{CLAUDE_SKILL_DIR\}/rules/wcag-semantic-html.md | Landmarks, headings, ARIA labels, form structure |
| Testing | $\{CLAUDE_SKILL_DIR\}/rules/wcag-testing.md | axe-core, Playwright a11y, screen reader testing |
POUR Exit Criteria
Concrete pass/fail thresholds for each WCAG 2.2 AA criterion — replaces vague "meets requirements" checks.
| Rule | File | Key Pattern |
|---|---|---|
| POUR Exit Criteria | $\{CLAUDE_SKILL_DIR\}/rules/pour-exit-criteria.md | Falsifiable checklist: image alt, contrast ratios, focus indicators, touch targets, ARIA states |
Static Anti-Patterns
Grep-able anti-patterns detectable via static analysis or code review — no browser needed.
| Rule | File | Key Pattern |
|---|---|---|
| A11y Anti-Patterns (Static) | $\{CLAUDE_SKILL_DIR\}/rules/a11y-antipatterns-static.md | Focus removal, missing labels, autoplay, icon-only buttons, div-click handlers |
Focus Management
Keyboard focus management patterns for accessible interactive widgets.
| Rule | File | Key Pattern |
|---|---|---|
| Focus Trap | $\{CLAUDE_SKILL_DIR\}/rules/focus-trap.md | Modal focus trapping, FocusScope, Escape key |
| Focus Restoration | $\{CLAUDE_SKILL_DIR\}/rules/focus-restoration.md | Return focus to trigger, focus first error |
| Keyboard Navigation | $\{CLAUDE_SKILL_DIR\}/rules/focus-keyboard-nav.md | Roving tabindex, skip links, arrow keys |
React Aria
Adobe React Aria hooks for building WCAG-compliant interactive UI.
| Rule | File | Key Pattern |
|---|---|---|
| Components | $\{CLAUDE_SKILL_DIR\}/rules/aria-components.md | useButton, useDialog, useMenu, FocusScope |
| Forms | $\{CLAUDE_SKILL_DIR\}/rules/aria-forms.md | useComboBox, useTextField, useListBox |
| Overlays | $\{CLAUDE_SKILL_DIR\}/rules/aria-overlays.md | useModalOverlay, useTooltip, usePopover |
Modern Web Accessibility
2026 best practices: native HTML first, cognitive inclusion, and honoring user preferences.
| Rule | File | Key Pattern |
|---|---|---|
| Native HTML First | $\{CLAUDE_SKILL_DIR\}/rules/wcag-native-html-first.md | <dialog>, <details>, native over custom ARIA |
| Cognitive Inclusion | $\{CLAUDE_SKILL_DIR\}/rules/wcag-cognitive-inclusion.md | ADHD/autism/dyslexia support, chunked content, notification management |
| User Preferences | $\{CLAUDE_SKILL_DIR\}/rules/wcag-user-preferences.md | prefers-reduced-motion, forced-colors, prefers-contrast, zoom |
Key Decisions
| Decision | Recommendation |
|---|---|
| Conformance level | WCAG 2.2 AA (legal standard: ADA, Section 508) |
| Contrast ratio | 4.5:1 normal text, 3:1 large text and UI components |
| Target size | 24px min (WCAG 2.5.8), 44px for touch |
| Focus indicator | 3px solid outline, 3:1 contrast |
| Component library | React Aria hooks for control, react-aria-components for speed |
| State management | react-stately hooks (designed for a11y) |
| Focus management | FocusScope for modals, roving tabindex for widgets |
| Testing | jest-axe (unit) + Playwright axe-core (E2E) |
Anti-Patterns (FORBIDDEN)
- Div soup: Using
<div>instead of semantic elements (<nav>,<main>,<article>) - Color-only information: Status indicated only by color without icon/text
- Missing labels: Form inputs without associated
<label>oraria-label - Keyboard traps: Focus that cannot escape without Escape key
- Removing focus outline:
outline: nonewithout replacement indicator - Positive tabindex: Using
tabindex > 0(disrupts natural order) - Div with onClick: Using
<div onClick>instead of<button>oruseButton - Manual focus in modals: Using
useEffect+ref.focus()instead ofFocusScope - Auto-playing media: Audio/video that plays without user action
- ARIA overuse: Using ARIA when semantic HTML suffices
Detailed Documentation
| Resource | Description |
|---|---|
$\{CLAUDE_SKILL_DIR\}/scripts/ | Templates: accessible form, focus trap, React Aria components |
$\{CLAUDE_SKILL_DIR\}/checklists/ | WCAG audit, focus management, React Aria component checklists |
$\{CLAUDE_SKILL_DIR\}/references/ | WCAG criteria reference, focus patterns, React Aria hooks API |
$\{CLAUDE_SKILL_DIR\}/references/ux-thresholds-quick.md | UI/UX thresholds quick reference: contrast, touch targets, cognitive load, typography, forms |
$\{CLAUDE_SKILL_DIR\}/examples/ | Complete accessible component examples |
Related Skills
ork:testing-e2e- E2E testing patterns including accessibility testingdesign-system-starter- Accessible component library patternsork:i18n-date-patterns- RTL layout and locale-aware formattingmotion-animation-patterns- Reduced motion and animation accessibility
Rules (14)
Grep-able anti-patterns detectable via static code analysis without a browser — HIGH
Accessibility Anti-Patterns: Static Detection
These patterns are detectable by grep, ESLint, or code review — no browser required.
Anti-Pattern Table
| Anti-Pattern | Detection Regex | WCAG | Fix |
|---|---|---|---|
| Focus removal | outline:\s*(none|0) without :focus-visible companion | 2.4.11 | Add :focus-visible ring with 3:1 contrast |
| Non-descriptive links | Link text /^(click here|read more|here|more|learn more)$/i | 2.4.4 | Use descriptive text meaningful out of context |
| Autoplay media | <(video|audio)[^>]*autoplay without muted | 1.4.2 | Add muted or remove autoplay |
| Missing language | <html(?![^>]*lang) | 3.1.1 | Add <html lang="en"> (or correct BCP 47 tag) |
| Disabled zoom | (user-scalable=no|maximum-scale=1) in viewport meta | 1.4.4 | Remove these restrictions entirely |
| SR content hidden wrong | display:\s*none|visibility:\s*hidden on ARIA-role elements | 1.3.1 | Use .sr-only (visually hidden, SR accessible) |
| Placeholder as label | <input[^>]*placeholder without nearby <label | 3.3.2 | Add <label for="id"> linked via matching id |
| Heading skip | <h[1-6] followed later by level +2 or more | 1.3.1 | Maintain sequential hierarchy (h1 > h2 > h3) |
| Image without alt | <img(?![^>]*\balt\b) | 1.1.1 | Add alt="description" or alt="" for decorative |
| Button without text | <button[^>]*>(\s*<[^/]) with no aria-label | 4.1.2 | Add aria-label="Action name" |
| Positive tabindex | tabindex="[1-9] | 2.4.3 | Use tabindex="0" or -1 only |
| Div/span click handler | <(div|span)[^>]*onClick | 4.1.2 | Replace with <button> or add role="button" + keyboard handler |
Detailed Fixes
Focus Removal
Incorrect:
/* Removes focus for all users including keyboard-only users */
* { outline: none; }
button:focus { outline: 0; }Correct:
/* Only hide outline for mouse users; preserve for keyboard users */
button:focus:not(:focus-visible) { outline: none; }
button:focus-visible {
outline: 3px solid #0052cc;
outline-offset: 2px;
}Placeholder as Label
Incorrect:
<input type="email" placeholder="Email address" />Correct:
<label for="email">Email address</label>
<input id="email" type="email" placeholder="you@example.com" aria-required="true" />Autoplay Media
Incorrect:
<video autoplay src="intro.mp4"></video>Correct:
<!-- Muted autoplay is allowed (no audio disruption) -->
<video autoplay muted loop src="intro.mp4"></video>
<!-- Or: remove autoplay entirely and provide play control -->
<video controls src="intro.mp4"></video>Icon-Only Button
Incorrect:
<button><svg><!-- search icon --></svg></button>Correct:
<button aria-label="Search">
<svg aria-hidden="true" focusable="false"><!-- search icon --></svg>
</button>Visually Hidden Content (SR-only)
Incorrect:
/* Hides from screen readers too */
.hidden-label { display: none; }Correct:
/* Visible to screen readers, hidden visually */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}ESLint Rule Mapping
| Anti-Pattern | ESLint Rule (eslint-plugin-jsx-a11y) |
|---|---|
| Image without alt | jsx-a11y/alt-text |
| Missing label | jsx-a11y/label-has-associated-control |
| Non-descriptive links | jsx-a11y/anchor-ambiguous-text |
| Div with click handler | jsx-a11y/click-events-have-key-events + jsx-a11y/no-static-element-interactions |
| Button without text | jsx-a11y/accessible-emoji + custom rule |
| Autoplay | jsx-a11y/media-has-caption |
Build accessible buttons, dialogs, and menus with React Aria keyboard support — HIGH
React Aria Components (useButton, useDialog, useMenu)
useButton - Accessible Button
import { useRef } from 'react';
import { useButton, useFocusRing, mergeProps } from 'react-aria';
import type { AriaButtonProps } from 'react-aria';
function Button(props: AriaButtonProps & { className?: string }) {
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps, isPressed } = useButton(props, ref);
const { focusProps, isFocusVisible } = useFocusRing();
return (
<button
{...mergeProps(buttonProps, focusProps)}
ref={ref}
className={`
px-4 py-2 rounded font-medium transition-all
${isPressed ? 'scale-95' : ''}
${isFocusVisible ? 'ring-2 ring-offset-2 ring-blue-500' : ''}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
{props.children}
</button>
);
}Key Props:
onPress- Triggered on click, tap, Enter, or SpaceisDisabled- Disables all interactionelementType- Custom element type (default: button)
useDialog - Modal Dialog
import { useRef } from 'react';
import { useDialog, useModalOverlay, FocusScope, mergeProps } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
function Modal({ state, title, children }) {
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay({}, state, ref);
const { dialogProps, titleProps } = useDialog({ 'aria-label': title }, ref);
return (
<div {...underlayProps} className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
<FocusScope contain restoreFocus autoFocus>
<div {...mergeProps(modalProps, dialogProps)} ref={ref} className="bg-white rounded-lg p-6">
<h2 {...titleProps} className="text-xl font-semibold mb-4">{title}</h2>
{children}
</div>
</FocusScope>
</div>
);
}useMenu - Dropdown Menu
import { useRef } from 'react';
import { useButton, useMenuTrigger, useMenu, useMenuItem, mergeProps } from 'react-aria';
import { useMenuTriggerState, useTreeState } from 'react-stately';
import { Item } from 'react-stately';
export function MenuButton(props: { label: string; onAction: (key: string) => void }) {
const state = useMenuTriggerState({});
const ref = useRef<HTMLButtonElement>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
const { buttonProps } = useButton(menuTriggerProps, ref);
return (
<div className="relative inline-block">
<button
{...buttonProps}
ref={ref}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2"
>
{props.label}
<span aria-hidden="true">▼</span>
</button>
{state.isOpen && (
<MenuPopup
{...menuProps}
autoFocus={state.focusStrategy}
onClose={state.close}
onAction={(key) => {
props.onAction(key as string);
state.close();
}}
/>
)}
</div>
);
}
function MenuPopup(props: any) {
const ref = useRef<HTMLUListElement>(null);
const state = useTreeState({ ...props, selectionMode: 'none' });
const { menuProps } = useMenu(props, state, ref);
return (
<ul {...menuProps} ref={ref} className="absolute top-full left-0 mt-1 min-w-[200px] bg-white border rounded shadow-lg py-1 z-50">
{[...state.collection].map((item) => (
<MenuItem key={item.key} item={item} state={state} onAction={props.onAction} onClose={props.onClose} />
))}
</ul>
);
}
function MenuItem({ item, state, onAction, onClose }: any) {
const ref = useRef<HTMLLIElement>(null);
const { menuItemProps, isFocused, isPressed } = useMenuItem(
{ key: item.key, onAction, onClose }, state, ref
);
return (
<li {...menuItemProps} ref={ref} className={`px-4 py-2 cursor-pointer ${isFocused ? 'bg-blue-50' : ''} ${isPressed ? 'bg-blue-100' : ''}`}>
{item.rendered}
</li>
);
}mergeProps Utility
Safely merge multiple prop objects (combines event handlers):
import { mergeProps } from 'react-aria';
const combinedProps = mergeProps(
{ onClick: handler1, className: 'base' },
{ onClick: handler2, className: 'extra' }
);
// Result: onClick calls both handlersHooks vs Components Decision
| Approach | Use When |
|---|---|
useButton hooks | Maximum control over rendering and styling |
Button from react-aria-components | Fast prototyping, less boilerplate |
Anti-Patterns
// NEVER use div with onClick for interactive elements
<div onClick={handleClick}>Click me</div> // Missing keyboard support!
// ALWAYS use useButton or native button
const { buttonProps } = useButton({ onPress: handleClick }, ref);
<div {...buttonProps} ref={ref}>Click me</div>
// NEVER forget aria-live for dynamic announcements
<div>{errorMessage}</div> // Screen readers won't announce!
// ALWAYS use aria-live for status updates
<div aria-live="polite" className="sr-only">{errorMessage}</div>Incorrect — div with onClick, no keyboard support:
<div onClick={handleClick} className="button">
Click me
</div>
// No keyboard access, no screen reader announcementCorrect — useButton hook provides full accessibility:
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps } = useButton({ onPress: handleClick }, ref);
return <button {...buttonProps} ref={ref}>Click me</button>;Create accessible form controls with React Aria labels and keyboard navigation — HIGH
React Aria Forms (useComboBox, useTextField, useListBox)
useComboBox - Accessible Autocomplete
import { useRef } from 'react';
import { useComboBox, useFilter } from 'react-aria';
import { useComboBoxState } from 'react-stately';
function ComboBox(props) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const inputRef = useRef(null), buttonRef = useRef(null), listBoxRef = useRef(null);
const { buttonProps, inputProps, listBoxProps, labelProps } = useComboBox(
{ ...props, inputRef, buttonRef, listBoxRef }, state
);
return (
<div className="relative inline-flex flex-col">
<label {...labelProps}>{props.label}</label>
<div className="flex">
<input {...inputProps} ref={inputRef} className="border rounded-l px-3 py-2" />
<button {...buttonProps} ref={buttonRef} className="border rounded-r px-2">▼</button>
</div>
{state.isOpen && (
<ul {...listBoxProps} ref={listBoxRef} className="absolute top-full w-full border bg-white">
{[...state.collection].map((item) => (
<li key={item.key} className="px-3 py-2 hover:bg-gray-100">{item.rendered}</li>
))}
</ul>
)}
</div>
);
}Features:
- Type-ahead filtering with
useFilter - Arrow keys navigate options, Enter selects, Escape closes
- Label associated with input via
labelProps aria-expandedindicates dropdown state
useTextField - Accessible Text Input
import { useRef } from 'react';
import { useTextField } from 'react-aria';
function TextField(props) {
const ref = useRef(null);
const { labelProps, inputProps, descriptionProps, errorMessageProps } = useTextField(props, ref);
return (
<div className="flex flex-col gap-1">
<label {...labelProps} className="font-medium">
{props.label}
</label>
<input
{...inputProps}
ref={ref}
className="border rounded px-3 py-2"
/>
{props.description && (
<div {...descriptionProps} className="text-sm text-gray-600">
{props.description}
</div>
)}
{props.errorMessage && (
<div {...errorMessageProps} className="text-sm text-red-600">
{props.errorMessage}
</div>
)}
</div>
);
}Key Props:
label- Accessible label textdescription- Helper text (linked viaaria-describedby)errorMessage- Error text (linked viaaria-describedby)isRequired- Addsaria-required="true"isInvalid- Addsaria-invalid="true"
useListBox - Accessible List with Selection
import { useRef } from 'react';
import { useListBox, useOption } from 'react-aria';
import { useListState } from 'react-stately';
import { Item } from 'react-stately';
function ListBox(props) {
const state = useListState(props);
const ref = useRef(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul {...listBoxProps} ref={ref} className="border rounded">
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
function Option({ item, state }) {
const ref = useRef(null);
const { optionProps, isSelected, isFocused } = useOption(
{ key: item.key }, state, ref
);
return (
<li
{...optionProps}
ref={ref}
className={`
px-3 py-2 cursor-pointer
${isSelected ? 'bg-blue-500 text-white' : ''}
${isFocused ? 'bg-gray-100' : ''}
`}
>
{item.rendered}
</li>
);
}
// Usage
<ListBox selectionMode="multiple">
<Item key="red">Red</Item>
<Item key="green">Green</Item>
<Item key="blue">Blue</Item>
</ListBox>Selection Modes:
"single"- Select one item"multiple"- Select multiple items"none"- No selection (display only)
useSelect - Dropdown Select
import { useRef } from 'react';
import { HiddenSelect, useSelect } from 'react-aria';
import { useSelectState } from 'react-stately';
function Select(props) {
const state = useSelectState(props);
const ref = useRef(null);
const { triggerProps, valueProps, menuProps } = useSelect(props, state, ref);
return (
<div className="relative inline-flex flex-col">
<HiddenSelect state={state} triggerRef={ref} label={props.label} />
<button
{...triggerProps}
ref={ref}
className="px-4 py-2 border rounded flex justify-between items-center"
>
<span {...valueProps}>
{state.selectedItem?.rendered || 'Select...'}
</span>
<span aria-hidden="true">▼</span>
</button>
{state.isOpen && (
<ListBoxPopup {...menuProps} state={state} />
)}
</div>
);
}react-stately Integration
| React Aria Hook | State Hook |
|---|---|
| useComboBox | useComboBoxState |
| useListBox | useListState |
| useSelect | useSelectState |
| useMenu | useTreeState |
| useCheckbox | useToggleState |
Anti-Patterns
// NEVER omit label associations
<input type="text" placeholder="Email" /> // No accessible name!
// ALWAYS associate labels properly
<label {...labelProps}>Email</label>
<input {...inputProps} />
// NEVER use placeholder as label
<input placeholder="Enter email" /> // Disappears on focus!
// ALWAYS provide visible label + optional placeholder
<label htmlFor="email">Email</label>
<input id="email" placeholder="user@example.com" />Incorrect — Placeholder as label, no explicit association:
<input type="text" placeholder="Enter your email" />
// Screen readers can't identify field purpose reliablyCorrect — useTextField with proper label association:
const { labelProps, inputProps } = useTextField({ label: 'Email' }, ref);
return (
<>
<label {...labelProps}>Email</label>
<input {...inputProps} ref={ref} />
</>
);Implement accessible overlays with React Aria focus trapping and restoration — HIGH
React Aria Overlays (useModalOverlay, useTooltip, usePopover)
useModalOverlay - Full Modal Dialog
import { useRef } from 'react';
import { useDialog, useModalOverlay, FocusScope, mergeProps } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
import { AnimatePresence, motion } from 'motion/react';
function Modal({ state, title, children }) {
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(
{ isDismissable: true },
state,
ref
);
const { dialogProps, titleProps } = useDialog({ 'aria-label': title }, ref);
return (
<AnimatePresence>
{state.isOpen && (
<>
{/* Backdrop */}
<motion.div
{...underlayProps}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/50"
/>
{/* Dialog */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<FocusScope contain restoreFocus autoFocus>
<motion.div
{...mergeProps(modalProps, dialogProps)}
ref={ref}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 pointer-events-auto"
>
<h2 {...titleProps} className="text-xl font-semibold mb-4">{title}</h2>
{children}
</motion.div>
</FocusScope>
</div>
</>
)}
</AnimatePresence>
);
}
// Usage
function App() {
const state = useOverlayTriggerState({});
return (
<>
<button onClick={state.open} className="px-4 py-2 bg-blue-500 text-white rounded">
Open Modal
</button>
<Modal state={state} title="Confirm Action">
<p className="mb-4 text-gray-700">Are you sure you want to proceed?</p>
<div className="flex gap-2 justify-end">
<button onClick={state.close} className="px-4 py-2 border rounded">Cancel</button>
<button onClick={() => { console.log('Confirmed'); state.close(); }} className="px-4 py-2 bg-blue-500 text-white rounded">
Confirm
</button>
</div>
</Modal>
</>
);
}Features:
- Focus trapped within modal via
FocusScope contain - Escape key closes modal
- Click outside dismisses (if
isDismissable) - Focus returns to trigger button via
restoreFocus - Motion animations for smooth entrance/exit
useTooltip - Accessible Tooltip
import { useRef } from 'react';
import { useTooltip, useTooltipTrigger } from 'react-aria';
import { useTooltipTriggerState } from 'react-stately';
import { AnimatePresence, motion } from 'motion/react';
function Tooltip({ children, content, delay = 0 }) {
const state = useTooltipTriggerState({ delay });
const ref = useRef<HTMLButtonElement>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger({}, state, ref);
return (
<>
<span {...triggerProps} ref={ref}>
{children}
</span>
<AnimatePresence>
{state.isOpen && (
<TooltipPopup {...tooltipProps}>{content}</TooltipPopup>
)}
</AnimatePresence>
</>
);
}
function TooltipPopup(props: any) {
const ref = useRef<HTMLDivElement>(null);
const { tooltipProps } = useTooltip(props, ref);
return (
<motion.div
{...tooltipProps}
ref={ref}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute z-50 px-3 py-1.5 bg-gray-900 text-white text-sm rounded shadow-lg"
>
{props.children}
</motion.div>
);
}Features:
- Shows on hover and focus
- Accessible via
aria-describedby - Configurable delay before showing
- Content on Hover/Focus (WCAG 1.4.13): dismissible, hoverable, persistent
usePopover - Non-Modal Overlay
import { useRef } from 'react';
import { usePopover, DismissButton, Overlay } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
function Popover({ state, children, ...props }) {
const popoverRef = useRef(null);
const { popoverProps, underlayProps } = usePopover(
{ ...props, popoverRef },
state
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={popoverRef}
className="absolute z-10 bg-white border rounded shadow-lg p-4"
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}Popover vs Modal:
| Feature | Popover | Modal |
|---|---|---|
| Focus containment | Soft (can escape) | Strict (trapped) |
| Backdrop | Invisible dismiss layer | Visible overlay |
| Use case | Dropdowns, color pickers | Confirmations, forms |
| Escape key | Closes | Closes |
| Click outside | Dismisses | Dismisses if isDismissable |
Overlay State Management
All overlays use useOverlayTriggerState:
import { useOverlayTriggerState } from 'react-stately';
const state = useOverlayTriggerState({});
// Properties
state.isOpen // boolean
state.open() // open overlay
state.close() // close overlay
state.toggle() // toggleConfirmation Dialog Pattern
Pre-built pattern for confirming destructive actions:
function ConfirmDialog({ state, title, message, onConfirm }) {
return (
<Modal state={state} title={title}>
<p className="text-gray-700 mb-6">{message}</p>
<div className="flex gap-2 justify-end">
<button onClick={state.close} className="px-4 py-2 border rounded">
Cancel
</button>
<button
onClick={() => { onConfirm(); state.close(); }}
className="px-4 py-2 bg-red-500 text-white rounded"
>
Confirm
</button>
</div>
</Modal>
);
}Anti-Patterns
// NEVER handle focus manually for modals
useEffect(() => { modalRef.current?.focus(); }, []); // Incomplete!
// ALWAYS use FocusScope for modals/overlays
<FocusScope contain restoreFocus autoFocus>
<div role="dialog">...</div>
</FocusScope>
// NEVER forget to restore focus on close
// useOverlayTriggerState + FocusScope restoreFocus handles this automaticallyIncorrect — Modal without focus management:
{isOpen && (
<div role="dialog" className="modal">
<h2>Confirm Action</h2>
<button onClick={onClose}>Close</button>
</div>
)}
// No focus trap, no focus restorationCorrect — useModalOverlay with FocusScope:
<FocusScope contain restoreFocus autoFocus>
<div {...mergeProps(modalProps, dialogProps)} ref={ref}>
<h2 {...titleProps}>Confirm Action</h2>
<button onClick={state.close}>Close</button>
</div>
</FocusScope>Ensure all interactive elements support keyboard navigation with roving tabindex — HIGH
Keyboard Navigation (WCAG 2.1.1, 2.4.3, 2.4.7)
Roving Tabindex
Only one item in a group has tabIndex=\{0\}; the rest have tabIndex=\{-1\}. Arrow keys move focus.
function TabList({ tabs, onSelect }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<HTMLButtonElement[]>([]);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
const keyMap: Record<string, number> = {
ArrowRight: (index + 1) % tabs.length,
ArrowLeft: (index - 1 + tabs.length) % tabs.length,
Home: 0, End: tabs.length - 1,
};
if (e.key in keyMap) {
e.preventDefault();
setActiveIndex(keyMap[e.key]);
tabRefs.current[keyMap[e.key]]?.focus();
}
};
return (
<div role="tablist">
{tabs.map((tab, i) => (
<button key={tab.id} ref={(el) => (tabRefs.current[i] = el!)}
role="tab" tabIndex={i === activeIndex ? 0 : -1}
aria-selected={i === activeIndex}
onKeyDown={(e) => handleKeyDown(e, i)}
onClick={() => { setActiveIndex(i); onSelect(tab); }}>
{tab.label}
</button>
))}
</div>
);
}useRovingTabindex Hook
Reusable hook for toolbars, menus, and lists:
type Orientation = 'horizontal' | 'vertical';
export function useRovingTabindex<T extends HTMLElement>(
itemCount: number,
orientation: Orientation = 'vertical'
) {
const [activeIndex, setActiveIndex] = useState(0);
const itemsRef = useRef<Map<number, T>>(new Map());
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
const keys = orientation === 'horizontal'
? { next: 'ArrowRight', prev: 'ArrowLeft' }
: { next: 'ArrowDown', prev: 'ArrowUp' };
let nextIndex: number | null = null;
if (event.key === keys.next) {
nextIndex = (activeIndex + 1) % itemCount;
} else if (event.key === keys.prev) {
nextIndex = (activeIndex - 1 + itemCount) % itemCount;
} else if (event.key === 'Home') {
nextIndex = 0;
} else if (event.key === 'End') {
nextIndex = itemCount - 1;
}
if (nextIndex !== null) {
event.preventDefault();
setActiveIndex(nextIndex);
itemsRef.current.get(nextIndex)?.focus();
}
}, [activeIndex, itemCount, orientation]);
const getItemProps = useCallback((index: number) => ({
ref: (element: T | null) => {
if (element) {
itemsRef.current.set(index, element);
} else {
itemsRef.current.delete(index);
}
},
tabIndex: index === activeIndex ? 0 : -1,
onFocus: () => setActiveIndex(index),
}), [activeIndex]);
return { activeIndex, setActiveIndex, handleKeyDown, getItemProps };
}
// Usage: Toolbar
function Toolbar() {
const { getItemProps, handleKeyDown } = useRovingTabindex<HTMLButtonElement>(
3, 'horizontal'
);
return (
<div role="toolbar" aria-label="Text formatting" onKeyDown={handleKeyDown}>
<button {...getItemProps(0)} aria-label="Bold"><BoldIcon /></button>
<button {...getItemProps(1)} aria-label="Italic"><ItalicIcon /></button>
<button {...getItemProps(2)} aria-label="Underline"><UnderlineIcon /></button>
</div>
);
}Skip Links
Allow keyboard users to bypass repeated navigation:
export function SkipLinks() {
return (
<nav aria-label="Skip links">
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<a href="#navigation" className="skip-link">
Skip to navigation
</a>
</nav>
);
}
// Layout usage
export function Layout({ children }) {
return (
<>
<SkipLinks />
<nav id="navigation" aria-label="Main navigation">
{/* navigation */}
</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
);
}.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}Focus Within Detection
export function useFocusWithin<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [isFocusWithin, setIsFocusWithin] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleFocusIn = () => setIsFocusWithin(true);
const handleFocusOut = (e: FocusEvent) => {
if (!element.contains(e.relatedTarget as Node)) {
setIsFocusWithin(false);
}
};
element.addEventListener('focusin', handleFocusIn);
element.addEventListener('focusout', handleFocusOut);
return () => {
element.removeEventListener('focusin', handleFocusIn);
element.removeEventListener('focusout', handleFocusOut);
};
}, []);
return { ref, isFocusWithin };
}Focus Indicator Styles
/* Use :focus-visible (not :focus) for keyboard-only indicators */
:focus-visible {
outline: 3px solid #0052cc;
outline-offset: 2px;
}
/* Ensure scroll margin for sticky headers */
:focus {
scroll-margin-top: var(--header-height, 64px);
}Anti-Patterns
// NEVER use positive tabindex - breaks natural tab order
<button tabIndex={5}>Bad</button>
// NEVER remove focus outline without replacement (WCAG 2.4.7)
button:focus { outline: none; }
// NEVER auto-focus without user expectation
useEffect(() => inputRef.current?.focus(), []);
// NEVER hide skip links permanently - must be visible on focus
.skip-link { display: none; }Incorrect — Removing focus outline globally:
*:focus {
outline: none;
}
/* Violates WCAG 2.4.7, keyboard users can't see focus */Correct — Using focus-visible for keyboard-only indicators:
:focus-visible {
outline: 3px solid #0052cc;
outline-offset: 2px;
}Restore focus to the correct element after closing overlays or submitting forms — HIGH
Focus Restoration (WCAG 2.4.3)
Basic Focus Restore
Return focus to the trigger element when an overlay closes:
function useRestoreFocus(isOpen: boolean) {
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLElement;
} else if (triggerRef.current) {
triggerRef.current.focus();
triggerRef.current = null;
}
}, [isOpen]);
}Reusable useFocusRestore Hook
import { useEffect, useRef } from 'react';
export function useFocusRestore(shouldRestore: boolean) {
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
previousFocusRef.current = document.activeElement as HTMLElement;
return () => {
if (shouldRestore && previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}, [shouldRestore]);
}
// Usage
function ConfirmationModal({ isOpen, onClose }) {
useFocusRestore(isOpen);
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2>Are you sure?</h2>
<button onClick={onClose}>Cancel</button>
<button onClick={handleConfirm}>Confirm</button>
</Modal>
);
}Focus First Error
After form validation failure, focus the first field with an error:
import { useEffect, useRef } from 'react';
export function useFocusFirstError(errors: Record<string, string>) {
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (Object.keys(errors).length === 0) return;
const firstErrorField = Object.keys(errors)[0];
const element = formRef.current?.querySelector(
`[name="${firstErrorField}"]`
) as HTMLElement;
element?.focus();
}, [errors]);
return formRef;
}
// Usage
function MyForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const formRef = useFocusFirstError(errors);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateForm();
setErrors(validationErrors);
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="email" type="email" aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email}</span>}
<button type="submit">Submit</button>
</form>
);
}Focus Confirmation Message
After a successful action, focus a confirmation for screen readers:
function FormWithConfirmation() {
const [submitted, setSubmitted] = useState(false);
const confirmationRef = useRef<HTMLDivElement>(null);
const handleSubmit = async () => {
await submitForm();
setSubmitted(true);
confirmationRef.current?.focus();
};
return (
<>
<form onSubmit={handleSubmit}>{/* fields */}</form>
{submitted && (
<div
ref={confirmationRef}
tabIndex={-1}
role="status"
aria-live="polite"
>
Form submitted successfully!
</div>
)}
</>
);
}Restoration Strategies
| Strategy | When to Use | Implementation |
|---|---|---|
| Trigger element | Closing modals/menus | Store document.activeElement before open |
| First error | Form validation failure | Query [name="firstErrorField"] |
| Confirmation message | Successful submission | Focus tabIndex=\{-1\} status element |
| Session storage | Page navigation | Save/restore via data-focus-id |
Common Mistakes
| Mistake | Fix |
|---|---|
| Not storing trigger ref before open | Capture document.activeElement immediately on open |
| Trigger element removed from DOM | Check element exists before calling .focus() |
| Not clearing ref after restore | Set triggerRef.current = null after focus |
Forgetting tabIndex=\{-1\} on non-interactive targets | Required for programmatic focus on divs |
Incorrect — Not restoring focus after modal closes:
function Modal({ isOpen, onClose }) {
return isOpen ? (
<div role="dialog">
<button onClick={onClose}>Close</button>
</div>
) : null;
// Focus doesn't return to trigger button
}Correct — Using useFocusRestore hook:
function Modal({ isOpen, onClose }) {
useFocusRestore(isOpen);
return isOpen ? (
<div role="dialog">
<button onClick={onClose}>Close</button>
</div>
) : null;
}Trap keyboard focus within modal dialogs with Escape key dismissal support — HIGH
Focus Trap (WCAG 2.1.1, 2.1.2)
React Aria FocusScope
The simplest and most reliable approach for modals and dialogs:
import { FocusScope } from 'react-aria';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true">
<FocusScope contain restoreFocus autoFocus>
<div className="modal-content">
{children}
<button onClick={onClose}>Close</button>
</div>
</FocusScope>
</div>
);
}FocusScope Props:
contain- Trap focus within childrenrestoreFocus- Restore focus to trigger on unmountautoFocus- Auto-focus first focusable element
Custom useFocusTrap Hook
When you need more control than FocusScope provides:
import { useEffect, useRef, useCallback } from 'react';
const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
const containerRef = useRef<T>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Store trigger element
useEffect(() => {
if (isActive) {
previousActiveElement.current = document.activeElement as HTMLElement;
} else if (previousActiveElement.current) {
previousActiveElement.current.focus();
previousActiveElement.current = null;
}
}, [isActive]);
// Trap focus within the container
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (!isActive || event.key !== 'Tab') return;
const container = containerRef.current;
if (!container) return;
const focusableElements = Array.from(
container.querySelectorAll(FOCUSABLE_SELECTOR)
) as HTMLElement[];
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}, [isActive]);
// Focus the first element when activated
useEffect(() => {
if (!isActive) return;
const container = containerRef.current;
if (!container) return;
const focusableElements = Array.from(
container.querySelectorAll(FOCUSABLE_SELECTOR)
) as HTMLElement[];
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}, [isActive]);
// Attach event listener
useEffect(() => {
if (!isActive) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isActive, handleKeyDown]);
return containerRef;
}Escape Key Handler
Every focus trap must have an Escape key handler:
export function useEscapeKey(onEscape: () => void, isActive: boolean = true) {
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onEscape();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onEscape, isActive]);
}Trap vs Contain
| Pattern | Use Case | Behavior |
|---|---|---|
FocusTrap (strict) | Modals, dialogs | Focus cannot escape at all |
FocusScope (soft) | Popovers, dropdowns | Focus contained but can escape |
Anti-Patterns
// NEVER trap focus without Escape key handler
<FocusTrap><div>No way out!</div></FocusTrap>
// NEVER handle focus manually for modals
useEffect(() => { modalRef.current?.focus(); }, []); // Incomplete!
// ALWAYS use FocusScope for modals/overlays
<FocusScope contain restoreFocus autoFocus>
<div role="dialog">...</div>
</FocusScope>Incorrect — Focus trap without escape mechanism:
function Modal({ isOpen, children }) {
const ref = useFocusTrap(isOpen);
return isOpen ? (
<div ref={ref} role="dialog">{children}</div>
) : null;
// No Escape key handler, user is trapped
}Correct — FocusScope with escape via Escape key:
function Modal({ isOpen, onClose, children }) {
useEscapeKey(onClose, isOpen);
return isOpen ? (
<FocusScope contain restoreFocus autoFocus>
<div role="dialog">{children}</div>
</FocusScope>
) : null;
}Concrete pass/fail exit criteria for each POUR principle mapped to WCAG 2.2 criteria — CRITICAL
POUR Exit Criteria (WCAG 2.2 AA)
Pass/fail thresholds for each principle. Every item must pass before marking a feature accessible.
Perceivable
- Every
<img>hasalt. Decorative images usealt="". Complex images usearia-describedbypointing to adjacent descriptive text. (1.1.1, Level A) - Normal text contrast >= 4.5:1 against its background. Large text (>= 18pt or >= 14pt bold) contrast >= 3:1. (1.4.3, AA)
- UI component boundaries (input borders, icon strokes, button outlines) contrast >= 3:1 against adjacent color. (1.4.11, AA)
- No information is conveyed by color alone — every color-coded element also has an icon, pattern, or visible text label. (1.4.1, A)
- Page content reflows without horizontal scrolling at 320px viewport width (equivalent to 400% zoom on 1280px display). No fixed-width containers wider than 320px. (1.4.10, AA)
- Text spacing overrides do not break layout:
line-height: 1.5,letter-spacing: 0.12em,word-spacing: 0.16em,paragraph spacing: 2emall applied simultaneously produce no clipped or overlapping content. (1.4.12, AA)
Operable
- Every interactive element (links, buttons, inputs, custom widgets) is reachable and activatable via keyboard alone. Tab order follows visual reading order (left-to-right, top-to-bottom for LTR). (2.1.1, A)
- No keyboard trap exists — pressing Escape or a documented key sequence always exits any component that receives focus. (2.1.2, A)
- A skip link to
<main id="main-content">is the first focusable element on every page. It becomes visible on focus. (2.4.1, A) - All focus indicators: minimum 2px perimeter outline, >= 3:1 contrast between focused and unfocused states. Default browser outlines are acceptable only if they pass the contrast check. (2.4.11, AA — WCAG 2.2)
- Touch targets are >= 24x24 CSS pixels. No adjacent interactive target falls within a 24px radius of another target's boundary. Primary CTAs should be >= 44x44px. (2.5.8, AA — WCAG 2.2)
- No content moves, blinks, scrolls, or auto-updates for more than 3 seconds without a mechanism to pause, stop, or hide it. (2.2.2, A)
- Page titles describe topic or purpose uniquely within the site (e.g., "Login — AppName", not just "Login"). (2.4.2, A)
Understandable
-
<html lang="xx">is set and matches the primary language of the page. Language changes within content uselangon the containing element. (3.1.1, A) - Every form input has an associated
<label for="id">, oraria-label, oraria-labelledby. Placeholder text alone does not count as a label. (3.3.2, A) - Error messages: identify the affected field by name, describe the cause of the error, and suggest a specific fix. Errors are announced to assistive technology via
aria-live="polite"orrole="alert". (3.3.1 + 3.3.3, A/AA) - No link text is "click here", "read more", "here", "more", or "link" when read out of context. Each link's accessible name uniquely identifies its destination or action. (2.4.4, A)
- Links that open in a new tab include a visible icon (e.g., external-link icon) with
aria-labelsupplement (e.g.,aria-label="Opens in new tab"). (2.4.4 advisory) - Navigation menus appear in the same relative order on every page where they repeat. (3.2.3, AA)
- Components with the same function have the same accessible name across all pages. (3.2.4, AA)
Example: Focus Indicator (2.4.11)
Incorrect:
/* Removes all focus indicators — keyboard users are blind */
*:focus { outline: none; }Correct:
/* 2px outline with sufficient contrast for focus visibility */
*:focus-visible {
outline: 2px solid var(--focus-ring, #005fcc);
outline-offset: 2px;
}Robust
- No duplicate
idattributes on interactive elements in the same document. No unclosed or improperly nested landmark elements (<main>,<nav>,<header>,<footer>). (4.1.1, A) - Custom interactive widgets expose correct ARIA state attributes:
- Toggle buttons:
aria-pressed="true|false" - Disclosure widgets:
aria-expanded="true|false" - Tabs:
aria-selected="true|false"on tab elements,role="tablist"on container - Custom checkboxes:
aria-checked="true|false|mixed" - Comboboxes:
aria-autocomplete,aria-activedescendant(4.1.2, A)
- Toggle buttons:
- Status messages (toasts, loading indicators, success confirmations, live regions) use
role="status"oraria-live="polite". Urgent alerts userole="alert"oraria-live="assertive". (4.1.3, AA) - All
aria-*attributes reference existing IDs. No orphanedaria-labelledbyoraria-describedbyvalues. (4.1.2, A)
Design for cognitive accessibility including ADHD, autism, and dyslexia support — HIGH
Cognitive Inclusion (WCAG 2.2 + 2026 Best Practices)
Principle
Accessibility extends beyond screen readers and keyboard nav. Design for how people think and process — reduce cognitive load, manage information density, and respect attention limitations.
Information Density Management
Incorrect — Wall of text with no structure:
<div className="content">
<p>{longParagraph1}</p>
<p>{longParagraph2}</p>
<p>{longParagraph3}</p>
<p>{longParagraph4}</p>
</div>Correct — Chunked content with clear hierarchy:
<article>
<h2>Getting Started</h2>
<p className="summary">{briefSummary}</p>
<section aria-labelledby="step-1">
<h3 id="step-1">Step 1: Install</h3>
<p>{shortInstruction}</p>
<pre><code>{codeExample}</code></pre>
</section>
<section aria-labelledby="step-2">
<h3 id="step-2">Step 2: Configure</h3>
<p>{shortInstruction}</p>
</section>
</article>Notification Patterns
Incorrect — Multiple simultaneous notifications:
function Notifications({ items }) {
return items.map(item => (
<div role="alert" className="toast">{item.message}</div>
));
}Correct — Queued, dismissible, non-overwhelming notifications:
function Notifications({ items }) {
const [visible, setVisible] = useState(items.slice(0, 1));
return (
<div aria-live="polite" aria-relevant="additions">
{visible.map(item => (
<div key={item.id} role="status" className="toast">
<span>{item.message}</span>
<button onClick={() => dismiss(item.id)} aria-label="Dismiss">
<CloseIcon aria-hidden="true" />
</button>
</div>
))}
{items.length > 1 && (
<p className="sr-only">{items.length - 1} more notifications</p>
)}
</div>
);
}Navigation Simplicity
- Limit primary nav to 5-7 items max
- Use consistent layout across all pages
- Provide breadcrumbs for deep navigation
- Always show where the user is (active state)
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/docs">Docs</a></li>
<li aria-current="page">Installation</li>
</ol>
</nav>Reading Level and Plain Language
| Guideline | Target |
|---|---|
| Sentence length | Under 25 words |
| Paragraph length | 2-4 sentences |
| Reading level | Grade 8 or lower for general content |
| Jargon | Define on first use or provide glossary |
| Abbreviations | Expand on first use with <abbr> |
<p>
Use <abbr title="Web Content Accessibility Guidelines">WCAG</abbr> to
make your site accessible.
</p>Cognitive Load Reduction
| Pattern | Implementation |
|---|---|
| Progressive disclosure | Show essential info first, details on demand |
| Consistent layouts | Same navigation, same patterns across pages |
| Clear error recovery | Tell users what went wrong and how to fix it |
| Autosave | Prevent data loss from attention lapses |
| Timeout warnings | Alert before session expiry with extend option |
Incorrect — All fields on one page:
<form>{/* 30 fields visible at once */}</form>Correct — Multi-step with progress:
<form aria-label="Account setup">
<div role="progressbar" aria-valuenow={2} aria-valuemin={1}
aria-valuemax={4} aria-label="Step 2 of 4" />
<fieldset>
<legend>Contact Information</legend>
{currentStepFields}
</fieldset>
<button type="button" onClick={prevStep}>Back</button>
<button type="button" onClick={nextStep}>Next</button>
</form>Audit Checklist
- No walls of text — content chunked with headings
- Max 1 notification visible at a time, all dismissible
- Navigation has 7 or fewer primary items
- Error messages explain how to fix the problem
- Multi-step processes show progress and allow going back
- Session timeouts warn users and allow extension
Meet WCAG 4.5:1 minimum contrast ratio for text and UI component readability — CRITICAL
Color Contrast (WCAG 1.4.3, 1.4.11)
Contrast Requirements
| Element Type | Minimum Ratio | WCAG Criterion |
|---|---|---|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 | 1.4.3 |
| Large text (>= 18pt / >= 14pt bold) | 3:1 | 1.4.3 |
| UI components (borders, icons, focus) | 3:1 | 1.4.11 |
| Focus indicators | 3:1 | 2.4.7 |
CSS Custom Properties
:root {
--text-primary: #1a1a1a; /* 16.1:1 on white - normal text */
--text-secondary: #595959; /* 7.0:1 on white - secondary */
--focus-ring: #0052cc; /* 7.3:1 - focus indicator */
}
/* High visibility focus indicator */
:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
/* Button border 3:1 contrast */
.button {
background: #ffffff;
border: 2px solid #757575; /* 4.5:1 on white */
}
/* Minimum target size (WCAG 2.5.8) */
button, a[role="button"], input[type="checkbox"] {
min-width: 24px;
min-height: 24px;
}
/* Touch-friendly target size */
@media (hover: none) {
button {
min-width: 44px;
min-height: 44px;
}
}Non-Color Status Indicators
Never convey information through color alone:
// FORBIDDEN: Color-only status
<span className="text-red-500">Error</span>
// CORRECT: Color + icon + text
<span className="text-red-500 flex items-center gap-1">
<AlertIcon aria-hidden="true" />
Error: Invalid email address
</span>Text Spacing (WCAG 1.4.12)
Content must remain usable when text spacing is adjusted:
body {
line-height: 1.5; /* at least 1.5x font size */
}
p {
margin-bottom: 2em; /* at least 2x font size */
}Reflow (WCAG 1.4.10)
Content must reflow without horizontal scrolling at 320px width:
/* Responsive design */
.card {
width: 100%;
max-width: 600px;
}
/* FORBIDDEN: Fixed width that forces horizontal scroll */
.card {
width: 800px;
}Testing Tools
- WebAIM Contrast Checker: webaim.org/resources/contrastchecker
- Chrome DevTools: Inspect > Color picker > Contrast ratio
- Lighthouse: Accessibility audit built into Chrome DevTools
Common Mistakes
| Mistake | Fix |
|---|---|
| Insufficient text contrast (#b3b3b3 = 2.1:1) | Use #595959 or darker (7.0:1+) |
| Removing focus outline globally | Use :focus-visible with custom outline |
| Color-only error indication | Add icon + text alongside color |
| Fixed-width layouts | Use responsive max-width + width: 100% |
| Tiny touch targets | Minimum 24px, 44px for touch devices |
Incorrect — Insufficient text contrast:
.secondary-text {
color: #b3b3b3; /* 2.1:1 ratio on white - fails WCAG AA */
}Correct — Meeting 4.5:1 contrast minimum:
.secondary-text {
color: #595959; /* 7.0:1 ratio on white - passes WCAG AA */
}Prefer native HTML elements over custom ARIA widgets for built-in accessibility — CRITICAL
Native HTML First (2026 Best Practice)
Principle
Use the platform. Native HTML elements (<dialog>, <details>, <select>, <button>) ship with keyboard handling, focus management, and screen reader announcements built in. Custom ARIA widgets should only be used when no native equivalent exists.
Native Element Replacements
| Instead of... | Use native | Why |
|---|---|---|
| Custom modal + focus trap JS | <dialog> + showModal() | Built-in focus trap, Escape close, inert backdrop |
| Custom accordion + ARIA | <details> / <summary> | Built-in expand/collapse, keyboard, screen reader |
| Custom dropdown + listbox ARIA | <select> | Built-in keyboard nav, mobile-optimized |
| Custom toggle + aria-checked | <input type="checkbox"> | Built-in state, label association, form submission |
<div onClick> | <button> | Built-in focus, Enter/Space, role announcement |
Dialog — Use <dialog> + showModal()
Incorrect — Custom modal with manual focus trap:
function Modal({ isOpen, onClose, children }) {
const ref = useRef(null);
useEffect(() => {
if (isOpen) ref.current?.focus();
// manual focus trap, Escape handler, inert siblings...
}, [isOpen]);
return isOpen ? (
<div role="dialog" aria-modal="true" ref={ref} tabIndex={-1}>
<div className="backdrop" onClick={onClose} />
{children}
</div>
) : null;
}Correct — Native <dialog> with built-in focus management:
function Modal({ children }) {
const dialogRef = useRef<HTMLDialogElement>(null);
return (
<dialog ref={dialogRef} onClose={() => dialogRef.current?.close()}>
{children}
<button onClick={() => dialogRef.current?.close()}>Close</button>
</dialog>
);
}
// Open with showModal() for built-in focus trap + backdrop + Escape
dialogRef.current?.showModal();Accordion — Use <details> / <summary>
Incorrect — Custom accordion with ARIA:
<div role="region">
<button aria-expanded={open} aria-controls="panel-1"
onClick={() => setOpen(!open)}>
Section Title
</button>
<div id="panel-1" role="region" hidden={!open}>{content}</div>
</div>Correct — Native <details> with CSS styling:
<details>
<summary>Section Title</summary>
<div className="panel">{content}</div>
</details>details summary { cursor: pointer; padding: 0.75rem; font-weight: 600; }
details[open] summary { border-bottom: 1px solid var(--border); }
details summary::marker { content: ''; } /* Note: ::marker on <summary> is supported in Chrome 89+, Firefox 68+, Safari 15.4+ */
details summary::after { content: '\25B6'; transition: transform 0.2s; }
details[open] summary::after { transform: rotate(90deg); }When Custom ARIA Is Justified
Use custom ARIA only when native elements cannot meet the requirement:
| Use case | Why native fails | ARIA approach |
|---|---|---|
| Combobox with async search | <datalist> lacks async, filtering control | role="combobox" + useComboBox |
| Tab panel widget | No native tab element | role="tablist" + role="tab" |
| Tree view | No native tree element | role="tree" + role="treeitem" |
| Menu with submenus | <menu> has limited support | role="menu" + role="menuitem" |
Audit Checklist
- Every
role="dialog"— can it be<dialog>? - Every custom accordion — can it be
<details>? - Every
<div onClick>— should it be<button>or<a>? - Every custom select — does
<select>+ CSS suffice? - ARIA attributes are only used where no native equivalent exists
Use semantic HTML and ARIA attributes for proper screen reader document structure — CRITICAL
Semantic HTML & ARIA (WCAG 1.3.1, 4.1.2)
Document Structure
<main>
<article>
<header><h1>Page Title</h1></header>
<section aria-labelledby="features-heading">
<h2 id="features-heading">Features</h2>
<ul><li>Feature 1</li></ul>
</section>
<aside aria-label="Related content">...</aside>
</article>
</main>Heading Hierarchy
Always follow h1-h6 order without skipping levels:
// CORRECT
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
// FORBIDDEN: Skipping levels
<h1>Page Title</h1>
<h3>Subsection</h3> // Skipped h2!ARIA Labels and States
// Icon-only button
<button aria-label="Save document">
<svg aria-hidden="true">...</svg>
</button>
// Form field with error
<input
id="email"
aria-required="true"
aria-invalid={!!error}
aria-describedby={error ? "email-error" : "email-hint"}
/>
{error && <p id="email-error" role="alert">{error}</p>}
// Custom widget with explicit role
<div
role="switch"
aria-checked={isEnabled}
aria-label="Enable notifications"
tabIndex={0}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') handleToggle();
}}
/>Form Structure
<form>
<fieldset>
<legend>Shipping Address</legend>
<label htmlFor="street">Street</label>
<input id="street" type="text" autoComplete="street-address" />
</fieldset>
</form>Live Regions
// Polite: status updates (default, avoids interruption)
<div role="status" aria-live="polite" aria-atomic="true">
{items.length} items in cart
</div>
// Assertive: errors that need immediate announcement
<div role="alert" aria-live="assertive">
{error}
</div>Page Language
<html lang="en">
<body>
<p>The French phrase <span lang="fr">Je ne sais quoi</span> means...</p>
</body>
</html>Skip Links
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<nav>...</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}WCAG 2.2 AA Checklist
| Criterion | Requirement | Test |
|---|---|---|
| 1.1.1 Non-text | Alt text for images | axe-core scan |
| 1.3.1 Info | Semantic HTML, headings | Manual + automated |
| 1.4.3 Contrast | 4.5:1 text, 3:1 large | WebAIM checker |
| 2.1.1 Keyboard | All functionality via keyboard | Tab through |
| 2.4.3 Focus Order | Logical tab sequence | Manual test |
| 2.4.7 Focus Visible | Clear focus indicator | Visual check |
| 2.4.11 Focus Not Obscured | Focus not hidden by sticky elements | scroll-margin-top |
| 2.5.8 Target Size | Min 24x24px interactive | CSS audit |
| 4.1.2 Name/Role/Value | Proper ARIA, labels | Screen reader test |
Anti-Patterns
- Div soup: Using
<div>where<nav>,<main>,<article>should be used - Empty links/buttons: Interactive elements without accessible names
- ARIA overuse: Using ARIA when semantic HTML suffices (prefer
<button>over<div role="button">) - Positive tabindex: Using
tabIndex > 0disrupts natural tab order - Decorative images without alt="": Must use
alt=""orrole="presentation"
Incorrect — Skipping heading levels:
<h1>Page Title</h1>
<h3>Subsection</h3> {/* Skipped h2 */}
// Screen readers rely on heading hierarchyCorrect — Following h1-h6 order without skipping:
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>Test accessibility compliance with axe-core automation and manual screen reader verification — CRITICAL
Accessibility Testing
Automated Testing with axe-core
Component-Level (jest-axe)
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('form has no accessibility violations', async () => {
const { container } = render(<AccessibleForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Page-Level (Playwright + axe-core)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('should not have any automatically detectable accessibility issues', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});ESLint Plugin
npm install --save-dev eslint-plugin-jsx-a11yCatches issues during development: missing alt text, missing labels, invalid ARIA attributes.
Screen Reader Testing
Test with at least one screen reader:
| Platform | Screen Reader | How to Enable |
|---|---|---|
| Windows | NVDA (free) | nvaccess.org |
| Windows | JAWS | freedomscientific.com |
| macOS/iOS | VoiceOver | Cmd+F5 to enable |
| Android | TalkBack | Built-in |
Verification Steps
- Navigate with Tab key, verify focus indicators
- Navigate with arrow keys (for custom widgets)
- Verify all images/icons are announced correctly
- Verify form labels are announced
- Verify error messages are announced via
role="alert" - Verify dynamic content updates are announced via
aria-live - Verify headings provide proper page structure
- Verify links are descriptive when read out of context
Manual Keyboard Testing
- Navigate entire UI with keyboard only (no mouse)
- Verify all interactive elements are reachable via Tab
- Test Tab, Shift+Tab, Arrow keys, Enter, Escape, Space
- Verify focus order follows visual/logical reading order
- Verify focus indicators are visible on all interactive elements
- Verify focus does not get trapped (except in modals, which need Escape)
- Check that focus returns after closing modals/menus
Automated Testing Tools
| Tool | Purpose | Coverage |
|---|---|---|
| axe DevTools | Browser extension | ~30-50% of WCAG issues |
| Lighthouse | Accessibility audit | Built into Chrome DevTools |
| WAVE | Visual feedback | Page-level audit |
| ESLint jsx-a11y | Catches issues during development | Code-level |
| Playwright + axe | CI/CD automated regression | Page-level |
CI/CD Integration
# GitHub Actions example
- name: Run accessibility tests
run: npx playwright test --grep @a11yCommon Mistakes
| Mistake | Fix |
|---|---|
| Only relying on automated tests | Automated tests catch 30-50%; manual + screen reader testing required |
| Testing only happy path | Test error states, loading states, empty states |
| Not testing keyboard navigation | Tab through entire flow manually |
| Ignoring screen reader announcements | Test with NVDA/VoiceOver for dynamic content |
Incorrect — Only running automated tests:
test('accessibility', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
// Only catches ~30-50% of issues
});Correct — Combining automated + manual testing:
test('accessibility - automated', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
// Plus manual checklist:
// - Tab through all interactive elements
// - Test with screen reader (NVDA/VoiceOver)
// - Verify focus indicators visible
// - Test error state announcementsHonor all user preferences including motion, color scheme, contrast, and zoom — HIGH
User Preferences (2026 Best Practices)
Principle
Users configure their OS for a reason. Honor every preference: reduced motion, color scheme, high contrast, contrast level, and text zoom. These are not optional enhancements — they are accessibility requirements.
prefers-reduced-motion
Disable or shorten animations for users with vestibular disorders.
Incorrect — Ignoring motion preference:
.card {
transition: transform 0.5s ease;
}
.card:hover {
transform: scale(1.1) rotate(2deg);
}Correct — Respecting reduced motion:
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: scale(1.05);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
}For JS: check window.matchMedia('(prefers-reduced-motion: reduce)').matches before animating.
prefers-color-scheme
Incorrect: body \{ background: #ffffff; color: #1a1a1a; \} — ignores user preference.
Correct — Adaptive color scheme with CSS custom properties:
:root { color-scheme: light dark; --bg: #ffffff; --text: #1a1a1a; }
@media (prefers-color-scheme: dark) {
:root { --bg: #111827; --text: #f3f4f6; }
}
body { background: var(--bg); color: var(--text); }forced-colors (Windows High Contrast)
Incorrect — Ignoring forced-colors mode:
.button {
background: var(--brand-blue);
border: none;
}
/* In High Contrast mode: button becomes invisible */Correct — Supporting forced-colors mode:
.button {
background: var(--brand-blue);
border: 2px solid transparent; /* becomes visible in forced-colors */
}
@media (forced-colors: active) {
.button {
border-color: ButtonText;
forced-color-adjust: none; /* Opt out only when custom treatment needed */
}
.icon {
forced-color-adjust: auto; /* Let system colors apply */
}
}Key forced-colors rules:
- Always use
border(not justbackground) for interactive elements - Test with Windows High Contrast Mode enabled
- Use system color keywords:
ButtonText,Canvas,LinkText,Highlight
prefers-contrast
Increase or decrease contrast beyond WCAG minimums on user request.
@media (prefers-contrast: more) {
:root {
--text: #000000;
--bg: #ffffff;
--border: #000000;
}
button {
border-width: 3px;
}
}
@media (prefers-contrast: less) {
:root {
--text: #333333;
--bg: #fafafa;
--border: #cccccc;
}
}Text Size and Zoom
Content must remain usable at 200% zoom (WCAG 1.4.4) and with user font-size overrides.
Incorrect: width: 960px; font-size: 14px; — fixed sizes break zoom.
Correct — Relative units that respect zoom:
.container { max-width: 60rem; width: 100%; }
.text { font-size: 0.875rem; line-height: 1.5; }
.content { overflow-wrap: break-word; overflow: visible; }Audit Checklist
- All animations wrapped in
prefers-reduced-motioncheck - Dark mode supported via
prefers-color-scheme - Tested in Windows High Contrast Mode (
forced-colors: active) -
prefers-contrast: moreincreases border widths and text contrast - Page usable at 200% browser zoom without horizontal scroll
- All text uses
rem/emunits, neverpxfor font-size
References (4)
Focus Patterns
Focus Management Patterns
Detailed implementation guide for keyboard navigation and focus management in React applications.
Focus Trap Algorithms
Basic Focus Trap
A focus trap restricts keyboard navigation to a specific container (modal, dialog, drawer).
Algorithm:
- Find all focusable elements within the container
- On Tab key, move focus to next focusable element
- On Shift+Tab, move focus to previous focusable element
- Wrap around at boundaries (first ↔ last)
- Prevent focus from escaping the container
Focusable Element Selector:
const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])'
].join(',');Implementation:
function trapFocus(container: HTMLElement, event: KeyboardEvent) {
const focusableElements = container.querySelectorAll(FOCUSABLE_SELECTOR);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (event.key !== 'Tab') return;
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}Return Focus on Close
When a modal closes, return focus to the element that triggered it.
function useFocusTrap(isOpen: boolean) {
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the currently focused element
triggerRef.current = document.activeElement as HTMLElement;
} else if (triggerRef.current) {
// Return focus when modal closes
triggerRef.current.focus();
triggerRef.current = null;
}
}, [isOpen]);
return triggerRef;
}Roving Tabindex Patterns
Roving tabindex allows arrow key navigation within a group (toolbar, menu, listbox).
Rules
- Only one element in the group has
tabindex="0"(the active item) - All other elements have
tabindex="-1"(reachable via script, not Tab) - Arrow keys move focus and update the active item
- Tab moves out of the group
Implementation
function useRovingTabindex<T extends HTMLElement>(
items: T[],
orientation: 'horizontal' | 'vertical' = 'vertical'
) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (event: React.KeyboardEvent) => {
const keys = orientation === 'horizontal'
? { next: 'ArrowRight', prev: 'ArrowLeft' }
: { next: 'ArrowDown', prev: 'ArrowUp' };
if (event.key === keys.next) {
event.preventDefault();
const nextIndex = (activeIndex + 1) % items.length;
setActiveIndex(nextIndex);
items[nextIndex]?.focus();
} else if (event.key === keys.prev) {
event.preventDefault();
const prevIndex = (activeIndex - 1 + items.length) % items.length;
setActiveIndex(prevIndex);
items[prevIndex]?.focus();
} else if (event.key === 'Home') {
event.preventDefault();
setActiveIndex(0);
items[0]?.focus();
} else if (event.key === 'End') {
event.preventDefault();
const lastIndex = items.length - 1;
setActiveIndex(lastIndex);
items[lastIndex]?.focus();
}
};
return {
activeIndex,
setActiveIndex,
handleKeyDown,
getItemProps: (index: number) => ({
tabIndex: index === activeIndex ? 0 : -1,
onFocus: () => setActiveIndex(index),
}),
};
}Example: Toolbar
function Toolbar() {
const buttons = useRef<HTMLButtonElement[]>([]);
const { getItemProps, handleKeyDown } = useRovingTabindex(buttons.current, 'horizontal');
return (
<div role="toolbar" onKeyDown={handleKeyDown}>
<button ref={el => buttons.current[0] = el!} {...getItemProps(0)}>
Bold
</button>
<button ref={el => buttons.current[1] = el!} {...getItemProps(1)}>
Italic
</button>
<button ref={el => buttons.current[2] = el!} {...getItemProps(2)}>
Underline
</button>
</div>
);
}Focus Restoration Strategies
Strategy 1: Save/Restore on Navigation
function useFocusRestore() {
useEffect(() => {
const savedFocus = sessionStorage.getItem('focusedElement');
if (savedFocus) {
const element = document.querySelector(`[data-focus-id="${savedFocus}"]`) as HTMLElement;
element?.focus();
sessionStorage.removeItem('focusedElement');
}
}, []);
const saveFocus = (id: string) => {
sessionStorage.setItem('focusedElement', id);
};
return { saveFocus };
}Strategy 2: Focus First Error
After form submission, focus the first validation error.
function focusFirstError(errors: Record<string, string>) {
const firstErrorField = Object.keys(errors)[0];
if (firstErrorField) {
const element = document.querySelector(`[name="${firstErrorField}"]`) as HTMLElement;
element?.focus();
}
}Strategy 3: Focus Confirmation Message
After a successful action, focus a confirmation message for screen readers.
function FormWithConfirmation() {
const [submitted, setSubmitted] = useState(false);
const confirmationRef = useRef<HTMLDivElement>(null);
const handleSubmit = async () => {
await submitForm();
setSubmitted(true);
confirmationRef.current?.focus();
};
return (
<>
<form onSubmit={handleSubmit}>{/* fields */}</form>
{submitted && (
<div
ref={confirmationRef}
tabIndex={-1}
role="status"
aria-live="polite"
>
Form submitted successfully!
</div>
)}
</>
);
}Skip Links Implementation
Skip links allow keyboard users to bypass repetitive navigation and jump to main content.
Basic Skip Link
function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
style={{
position: 'absolute',
left: '-9999px',
zIndex: 999,
}}
onFocus={(e) => {
e.currentTarget.style.left = '0';
}}
onBlur={(e) => {
e.currentTarget.style.left = '-9999px';
}}
>
Skip to main content
</a>
);
}CSS Approach (Preferred)
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
padding: 1rem;
background: var(--color-primary);
color: white;
}
.skip-link:focus {
left: 0;
top: 0;
}Multiple Skip Links
function SkipLinks() {
return (
<nav aria-label="Skip links">
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<a href="#navigation" className="skip-link">
Skip to navigation
</a>
<a href="#footer" className="skip-link">
Skip to footer
</a>
</nav>
);
}Usage:
function Layout({ children }) {
return (
<>
<SkipLinks />
<nav id="navigation">{/* nav */}</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
<footer id="footer">{/* footer */}</footer>
</>
);
}Important: Add tabIndex=\{-1\} to target elements so they receive focus programmatically.
Advanced Patterns
Focus Within Detection
function useFocusWithin<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [isFocusWithin, setIsFocusWithin] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleFocusIn = () => setIsFocusWithin(true);
const handleFocusOut = (e: FocusEvent) => {
if (!element.contains(e.relatedTarget as Node)) {
setIsFocusWithin(false);
}
};
element.addEventListener('focusin', handleFocusIn);
element.addEventListener('focusout', handleFocusOut);
return () => {
element.removeEventListener('focusin', handleFocusIn);
element.removeEventListener('focusout', handleFocusOut);
};
}, []);
return { ref, isFocusWithin };
}Escape Key to Close
function useEscapeKey(onEscape: () => void, isActive: boolean) {
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onEscape();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onEscape, isActive]);
}Testing Focus Management
Manual Testing Checklist
- Navigate entire UI with keyboard only (no mouse)
- Verify all interactive elements are reachable
- Check that focus indicator is visible
- Test Tab, Shift+Tab, Arrow keys, Escape, Enter
- Verify focus doesn't get trapped unexpectedly
- Check that focus returns after closing dialogs
Automated Testing with Playwright
test('modal traps focus', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open Modal' }).click();
// First focusable element
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Close' })).toBeFocused();
// Last focusable element
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Submit' })).toBeFocused();
// Wrap around
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Close' })).toBeFocused();
});Common Mistakes
| Mistake | Fix |
|---|---|
| Removing focus outline globally | Use :focus-visible to show only for keyboard |
| Focus trap without escape hatch | Always allow Escape key to close |
| Not returning focus after modal close | Store trigger element and refocus it |
Setting tabindex="0" on all items in roving group | Only the active item should be tabindex="0" |
| Skip link always visible | Only show on focus (screen reader users need it) |
Last Updated: 2026-01-16
React Aria Hooks
React Aria Hooks Reference
Comprehensive API reference for Adobe's React Aria hooks with React 19 patterns.
Installation
npm install react-aria react-statelyPeer Dependencies:
- React 19.x
- react-dom 19.x
Button Hooks
useButton
Creates accessible buttons with keyboard, pointer, and focus support.
import { useRef } from 'react';
import { useButton, useFocusRing, mergeProps } from 'react-aria';
import type { AriaButtonProps } from 'react-aria';
function Button(props: AriaButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps, isPressed } = useButton(props, ref);
const { focusProps, isFocusVisible } = useFocusRing();
return (
<button
{...mergeProps(buttonProps, focusProps)}
ref={ref}
className={`
px-4 py-2 rounded
${isPressed ? 'scale-95' : ''}
${isFocusVisible ? 'ring-2 ring-blue-500' : ''}
`}
>
{props.children}
</button>
);
}Key Props:
onPress- Triggered on click or Enter/SpaceisDisabled- Disables interactiontype- Button type (button, submit, reset)elementType- Custom element type (default: button)
Returns:
buttonProps- Spread on button elementisPressed- Current press state
useToggleButton
Toggle buttons with on/off states (like icon toggles).
import { useRef } from 'react';
import { useToggleButton } from 'react-aria';
import { useToggleState } from 'react-stately';
function ToggleButton(props) {
const state = useToggleState(props);
const ref = useRef(null);
const { buttonProps, isPressed } = useToggleButton(props, state, ref);
return (
<button
{...buttonProps}
ref={ref}
className={state.isSelected ? 'bg-blue-500 text-white' : 'bg-gray-200'}
>
{props.children}
</button>
);
}
// Usage
<ToggleButton onChange={(isSelected) => console.log(isSelected)}>
Toggle Me
</ToggleButton>Selection Hooks
useListBox
Accessible list with single/multiple selection, keyboard navigation, and typeahead.
import { useRef } from 'react';
import { useListBox, useOption } from 'react-aria';
import { useListState } from 'react-stately';
import { Item } from 'react-stately';
function ListBox(props) {
const state = useListState(props);
const ref = useRef(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul {...listBoxProps} ref={ref} className="border rounded">
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
function Option({ item, state }) {
const ref = useRef(null);
const { optionProps, isSelected, isFocused } = useOption(
{ key: item.key },
state,
ref
);
return (
<li
{...optionProps}
ref={ref}
className={`
px-3 py-2 cursor-pointer
${isSelected ? 'bg-blue-500 text-white' : ''}
${isFocused ? 'bg-gray-100' : ''}
`}
>
{item.rendered}
</li>
);
}
// Usage
<ListBox selectionMode="multiple">
<Item key="red">Red</Item>
<Item key="green">Green</Item>
<Item key="blue">Blue</Item>
</ListBox>Key Props:
selectionMode- "single", "multiple", "none"disallowEmptySelection- Prevent deselecting last itemonSelectionChange- Callback with Set of keys
useSelect
Dropdown select with keyboard navigation and proper ARIA semantics.
import { useRef } from 'react';
import { HiddenSelect, useSelect } from 'react-aria';
import { useSelectState } from 'react-stately';
import { Item } from 'react-stately';
function Select(props) {
const state = useSelectState(props);
const ref = useRef(null);
const { triggerProps, valueProps, menuProps } = useSelect(props, state, ref);
return (
<div className="relative inline-flex flex-col">
<HiddenSelect state={state} triggerRef={ref} label={props.label} />
<button
{...triggerProps}
ref={ref}
className="px-4 py-2 border rounded flex justify-between items-center"
>
<span {...valueProps}>
{state.selectedItem?.rendered || 'Select...'}
</span>
<span aria-hidden="true">▼</span>
</button>
{state.isOpen && (
<ListBoxPopup {...menuProps} state={state} />
)}
</div>
);
}Menu Hooks
useMenu / useMenuItem
Dropdown menus with keyboard navigation and submenus.
import { useRef } from 'react';
import { useMenu, useMenuItem, useMenuTrigger } from 'react-aria';
import { useMenuTriggerState } from 'react-stately';
function MenuButton(props) {
const state = useMenuTriggerState(props);
const ref = useRef(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
return (
<div className="relative">
<button {...menuTriggerProps} ref={ref}>
Actions ▼
</button>
{state.isOpen && (
<Menu {...menuProps} onAction={props.onAction} onClose={state.close} />
)}
</div>
);
}
function Menu(props) {
const ref = useRef(null);
const state = useTreeState(props);
const { menuProps } = useMenu(props, state, ref);
return (
<ul {...menuProps} ref={ref} className="absolute mt-1 border bg-white rounded shadow">
{[...state.collection].map((item) => (
<MenuItem key={item.key} item={item} state={state} onAction={props.onAction} onClose={props.onClose} />
))}
</ul>
);
}
function MenuItem({ item, state, onAction, onClose }) {
const ref = useRef(null);
const { menuItemProps } = useMenuItem(
{ key: item.key, onAction, onClose },
state,
ref
);
return (
<li {...menuItemProps} ref={ref} className="px-4 py-2 hover:bg-gray-100 cursor-pointer">
{item.rendered}
</li>
);
}Overlay Hooks
useDialog
Modal dialogs with proper ARIA semantics.
import { useRef } from 'react';
import { useDialog } from 'react-aria';
function Dialog({ title, children, ...props }) {
const ref = useRef(null);
const { dialogProps, titleProps } = useDialog(props, ref);
return (
<div {...dialogProps} ref={ref} className="bg-white rounded-lg p-6">
<h2 {...titleProps} className="text-xl font-semibold mb-4">
{title}
</h2>
{children}
</div>
);
}useModalOverlay
Full-screen overlay with focus management and dismissal.
import { useRef } from 'react';
import { useModalOverlay, FocusScope } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
function Modal({ state, title, children }) {
const ref = useRef(null);
const { modalProps, underlayProps } = useModalOverlay(
{ isDismissable: true },
state,
ref
);
return (
<div
{...underlayProps}
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"
>
<FocusScope contain restoreFocus autoFocus>
<div {...modalProps} ref={ref}>
<Dialog title={title}>{children}</Dialog>
</div>
</FocusScope>
</div>
);
}
// Usage with state management
function App() {
const state = useOverlayTriggerState({});
return (
<>
<button onClick={state.open}>Open Modal</button>
{state.isOpen && (
<Modal state={state} title="Example">
<p>Modal content here</p>
<button onClick={state.close}>Close</button>
</Modal>
)}
</>
);
}useTooltip / useTooltipTrigger
Accessible tooltips with hover/focus triggers.
import { useRef } from 'react';
import { useTooltip, useTooltipTrigger } from 'react-aria';
import { useTooltipTriggerState } from 'react-stately';
function TooltipTrigger({ children, tooltip, ...props }) {
const state = useTooltipTriggerState(props);
const ref = useRef(null);
const { triggerProps, tooltipProps } = useTooltipTrigger({}, state, ref);
return (
<>
<button {...triggerProps} ref={ref}>
{children}
</button>
{state.isOpen && (
<Tooltip {...tooltipProps}>{tooltip}</Tooltip>
)}
</>
);
}
function Tooltip(props) {
const ref = useRef(null);
const { tooltipProps } = useTooltip(props, ref);
return (
<div
{...tooltipProps}
ref={ref}
className="absolute z-50 px-2 py-1 bg-gray-900 text-white text-sm rounded"
>
{props.children}
</div>
);
}usePopover
Non-modal popovers for dropdowns, color pickers, etc.
import { useRef } from 'react';
import { usePopover, DismissButton, Overlay } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
function Popover({ state, children, ...props }) {
const popoverRef = useRef(null);
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef,
},
state
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={popoverRef}
className="absolute z-10 bg-white border rounded shadow-lg p-4"
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}
// Usage
function App() {
const state = useOverlayTriggerState({});
return (
<>
<button onClick={state.open}>Open Popover</button>
{state.isOpen && (
<Popover state={state}>
<p>Popover content</p>
</Popover>
)}
</>
);
}Form Hooks
useTextField
Accessible text inputs with label association.
import { useRef } from 'react';
import { useTextField } from 'react-aria';
function TextField(props) {
const ref = useRef(null);
const { labelProps, inputProps, descriptionProps, errorMessageProps } = useTextField(props, ref);
return (
<div className="flex flex-col gap-1">
<label {...labelProps} className="font-medium">
{props.label}
</label>
<input
{...inputProps}
ref={ref}
className="border rounded px-3 py-2"
/>
{props.description && (
<div {...descriptionProps} className="text-sm text-gray-600">
{props.description}
</div>
)}
{props.errorMessage && (
<div {...errorMessageProps} className="text-sm text-red-600">
{props.errorMessage}
</div>
)}
</div>
);
}Focus Management
FocusScope
Manages focus containment and restoration for overlays.
Props:
contain- Trap focus within childrenrestoreFocus- Restore focus to trigger on unmountautoFocus- Auto-focus first focusable element
import { FocusScope } from 'react-aria';
<FocusScope contain restoreFocus autoFocus>
<div role="dialog">
<button>First focusable</button>
<button>Second focusable</button>
</div>
</FocusScope>useFocusRing
Detects keyboard focus for styling focus indicators.
import { useFocusRing } from 'react-aria';
function Component() {
const { focusProps, isFocusVisible } = useFocusRing();
return (
<button
{...focusProps}
className={isFocusVisible ? 'ring-2 ring-blue-500' : ''}
>
Focusable
</button>
);
}Utility Functions
mergeProps
Safely merges multiple prop objects (handles event handlers, className, etc.).
import { mergeProps } from 'react-aria';
const combinedProps = mergeProps(
{ onClick: handler1, className: 'base' },
{ onClick: handler2, className: 'extra' }
);
// Result: onClick calls both handlers, className="base extra"Integration with react-stately
React Aria hooks require state management from react-stately:
| Hook | State Hook |
|---|---|
| useSelect | useSelectState |
| useListBox | useListState |
| useComboBox | useComboBoxState |
| useMenu | useTreeState |
| useModalOverlay | useOverlayTriggerState |
import { useListBox } from 'react-aria';
import { useListState } from 'react-stately';
const state = useListState(props);
const { listBoxProps } = useListBox(props, state, ref);TypeScript Support
All hooks include TypeScript types from @types/react-aria:
import type { AriaButtonProps, AriaDialogProps } from 'react-aria';
function MyButton(props: AriaButtonProps) {
// Full type safety
}Resources
Ux Thresholds Quick
UI/UX Thresholds — Cognitive Science Quick Reference
Contrast & Color
- Text on background: >= 4.5:1 (normal), >= 3:1 (large text >= 18pt)
- UI components: >= 3:1 against adjacent colors
- Focus indicators: >= 3:1 contrast, minimum 2px perimeter
- Never use color as sole information carrier
Touch & Targets
- Touch devices: minimum 44x44px interactive targets
- Desktop: minimum 24x24px, no adjacent target within 24px
- Primary CTA in thumb zone (bottom 2/3 of mobile screen)
- Destructive actions: smaller targets or require confirmation (Fitts's Law)
Cognitive Load
- Max 5-7 items in any list/menu before grouping (Miller's Law 4±1)
- Decision time doubles per doubling of options (Hick's Law) — use progressive disclosure
- Acknowledge interactions within 400ms (Doherty Threshold)
- Recognition over recall: show options, don't ask users to remember
Typography & Readability
- Line length: 50-75 characters (use
max-width: 65ch) - Line-height: 1.4-1.6x for body text
- No true black (#000) on pure white (#fff) — temper contrast
Forms & Errors
- Top-aligned labels (optimal for all contexts)
- Error messages: name the field + describe cause + suggest fix
- Blame the system, not the user ("We couldn't process..." not "Invalid input")
- Inline validation on blur, not on keystroke
- Mark optional fields, not required (invert the assumption)
Dark Pattern Red Flags
Reject these 13 patterns: confirmshaming, roach motel, misdirection, hidden costs, trick questions, disguised ads, forced continuity, friend spam, privacy zuckering, bait-and-switch, false urgency, nagging, visual interference.
Wcag Criteria
WCAG 2.2 Level AA Criteria Reference
Complete guide to Web Content Accessibility Guidelines 2.2 AA requirements.
Principle 1: Perceivable
Information and user interface components must be presentable to users in ways they can perceive.
1.1 Text Alternatives
1.1.1 Non-text Content (Level A)
All non-text content (images, icons, charts) must have text alternatives:
// ✅ Informative image
<img src="chart.png" alt="Revenue increased by 40% in Q4" />
// ✅ Decorative image
<img src="background.jpg" alt="" role="presentation" />
// ✅ Icon button
<button aria-label="Save document">
<svg aria-hidden="true"><path d="..." /></svg>
</button>
// ❌ Missing alt text
<img src="photo.jpg" />1.3 Adaptable
1.3.1 Info and Relationships (Level A)
Information structure must be programmatically determined:
// ✅ Semantic HTML
<form>
<fieldset>
<legend>Shipping Address</legend>
<label htmlFor="street">Street</label>
<input id="street" type="text" />
</fieldset>
</form>
// ❌ Div soup
<div class="form">
<div class="group">
<span>Shipping Address</span>
<span>Street</span>
<input />
</div>
</div>1.3.2 Meaningful Sequence (Level A)
Reading order must match visual presentation:
// ✅ DOM order matches visual order
<header>...</header>
<main>...</main>
<aside>...</aside>
// ❌ Using CSS to reorder without adjusting HTML
<aside style={{ order: -1 }}>...</aside>
<main>...</main>
<header>...</header>1.3.5 Identify Input Purpose (Level AA)
Form fields must have autocomplete attributes:
<input
type="email"
name="email"
autoComplete="email"
id="user-email"
/>
<input
type="tel"
name="phone"
autoComplete="tel"
id="user-phone"
/>1.4 Distinguishable
1.4.3 Contrast (Minimum) (Level AA)
Text contrast ratios:
- Normal text (< 18pt / < 14pt bold): 4.5:1 minimum
- Large text (≥ 18pt / ≥ 14pt bold): 3:1 minimum
/* ✅ High contrast for normal text */
:root {
--text-on-white: #1a1a1a; /* 16.1:1 */
--text-secondary: #595959; /* 7.0:1 */
}
/* ❌ Insufficient contrast */
:root {
--text-gray: #b3b3b3; /* 2.1:1 - FAIL */
}Tools: WebAIM Contrast Checker, Chrome DevTools
1.4.10 Reflow (Level AA)
Content must reflow without horizontal scrolling at 320px width (400% zoom).
/* ✅ Responsive design */
.card {
width: 100%;
max-width: 600px;
}
/* ❌ Fixed width */
.card {
width: 800px; /* Forces horizontal scroll on small screens */
}1.4.11 Non-text Contrast (Level AA)
UI components and graphical objects must have 3:1 contrast against adjacent colors:
- Form field borders
- Button boundaries
- Focus indicators
- Icons
- Chart elements
/* ✅ Button border 3:1 contrast */
.button {
background: #ffffff;
border: 2px solid #757575; /* 4.5:1 on white */
}
/* ✅ Focus indicator 3:1 contrast */
:focus-visible {
outline: 3px solid #0052cc; /* 7.3:1 on white */
}1.4.12 Text Spacing (Level AA)
Content must not lose information when text spacing is adjusted:
- Line height: at least 1.5x font size
- Paragraph spacing: at least 2x font size
- Letter spacing: at least 0.12x font size
- Word spacing: at least 0.16x font size
/* ✅ Accessible text spacing */
body {
line-height: 1.5;
}
p {
margin-bottom: 2em;
}1.4.13 Content on Hover or Focus (Level AA)
Additional content triggered by hover/focus must be:
- Dismissible (Esc key)
- Hoverable (pointer can move over it)
- Persistent (doesn't disappear until dismissed)
// ✅ Tooltip with Radix UI
<Tooltip.Root>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>
Tooltip text
<Tooltip.Arrow />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>Principle 2: Operable
User interface components and navigation must be operable.
2.1 Keyboard Accessible
2.1.1 Keyboard (Level A)
All functionality must be available via keyboard:
// ✅ Keyboard accessible
<button onClick={handleClick}>Click me</button>
// ❌ Mouse-only interaction
<div onClick={handleClick}>Click me</div>
// ✅ If using div, add keyboard support
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleClick();
}}
>
Click me
</div>2.1.2 No Keyboard Trap (Level A)
Focus must not get trapped:
// ✅ Modal with focus trap (can exit with Esc)
import { Dialog } from '@radix-ui/react-dialog';
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
{/* Focus trapped here, but Esc closes */}
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>2.1.4 Character Key Shortcuts (Level A)
Single-key shortcuts must be remappable or disabled:
// ✅ Require modifier key
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);2.4 Navigable
2.4.1 Bypass Blocks (Level A)
Provide skip links to bypass repeated content:
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<nav>...</nav>
<main id="main-content">
<h1>Page Title</h1>
{/* Main content */}
</main>/* ✅ Visible on focus */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}2.4.3 Focus Order (Level A)
Tab order must follow logical reading sequence:
// ✅ Natural tab order
<form>
<input tabIndex={0} /> {/* Natural order */}
<input tabIndex={0} />
<button type="submit">Submit</button>
</form>
// ❌ Positive tabindex (disrupts natural order)
<button tabIndex={3}>Third</button>
<button tabIndex={1}>First</button>
<button tabIndex={2}>Second</button>2.4.7 Focus Visible (Level AA)
Keyboard focus must be clearly visible:
/* ✅ High visibility focus indicator */
:focus-visible {
outline: 3px solid #0052cc;
outline-offset: 2px;
}
/* ❌ Removed focus outline */
button:focus {
outline: none; /* FORBIDDEN without replacement */
}2.4.11 Focus Not Obscured (Minimum) (Level AA - NEW in WCAG 2.2)
Focused element must not be entirely hidden by sticky headers/footers:
/* ✅ Ensure scroll margin for sticky header */
:root {
--header-height: 64px;
}
:focus {
scroll-margin-top: var(--header-height);
}2.5 Input Modalities
2.5.1 Pointer Gestures (Level A)
All multipoint/path-based gestures must have single-pointer alternatives:
// ✅ Pinch-to-zoom alternative
<button onClick={handleZoomIn}>Zoom In</button>
<button onClick={handleZoomOut}>Zoom Out</button>
// ✅ Swipe alternative
<button onClick={handlePrevious}>Previous</button>
<button onClick={handleNext}>Next</button>2.5.2 Pointer Cancellation (Level A)
Actions should complete on up event, not down:
// ✅ Click completes on mouse up
<button onClick={handleClick}>Click me</button>
// ❌ Action on mouse down
<button onMouseDown={handleClick}>Click me</button>2.5.3 Label in Name (Level A)
Accessible name must include visible text:
// ✅ Aria-label matches visible text
<button aria-label="Save document">Save</button>
// ❌ Aria-label doesn't match visible text
<button aria-label="Submit form">Save</button>2.5.7 Dragging Movements (Level AA - NEW in WCAG 2.2)
Dragging functionality must have single-pointer alternative:
// ✅ Drag-and-drop with keyboard alternative
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="list">
{(provided) => (
<ul {...provided.droppableProps} ref={provided.innerRef}>
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{/* Item with move up/down buttons */}
<button onClick={() => moveUp(index)}>↑</button>
<button onClick={() => moveDown(index)}>↓</button>
</Draggable>
))}
</ul>
)}
</Droppable>
</DragDropContext>2.5.8 Target Size (Minimum) (Level AA - NEW in WCAG 2.2)
Interactive elements must be at least 24x24 CSS pixels (except inline links):
/* ✅ Minimum target size */
button, a[role="button"], input[type="checkbox"] {
min-width: 24px;
min-height: 24px;
}
/* ✅ Touch-friendly target size */
@media (hover: none) {
button {
min-width: 44px;
min-height: 44px;
}
}Principle 3: Understandable
Information and operation of user interface must be understandable.
3.1 Readable
3.1.1 Language of Page (Level A)
Page language must be specified:
<html lang="en">3.1.2 Language of Parts (Level AA)
Changes in language must be marked:
<p>The French phrase <span lang="fr">Je ne sais quoi</span> means...</p>3.2 Predictable
3.2.1 On Focus (Level A)
Focus must not trigger unexpected context changes:
// ✅ Submit on button click
<form onSubmit={handleSubmit}>
<input type="text" />
<button type="submit">Submit</button>
</form>
// ❌ Submit on focus change
<select onChange={handleSubmit}>...</select>3.2.2 On Input (Level A)
User input must not automatically trigger context changes:
// ✅ Button to apply filter
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
...
</select>
<button onClick={applyFilter}>Apply</button>
// ❌ Auto-submit on select change
<select onChange={(e) => { setFilter(e.target.value); form.submit(); }}>3.2.3 Consistent Navigation (Level AA)
Navigation must be consistent across pages:
// ✅ Same navigation on every page
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
</nav>3.2.4 Consistent Identification (Level AA)
Components with same functionality must be identified consistently:
// ✅ Consistent icon and label
<button aria-label="Save document"><SaveIcon /></button>
// ❌ Inconsistent labeling
<button aria-label="Save">...</button>
<button aria-label="Store document">...</button>3.3 Input Assistance
3.3.1 Error Identification (Level A)
Errors must be identified and described:
// ✅ Error message with role="alert"
{error && (
<p id="email-error" role="alert" className="text-error">
{error}
</p>
)}
<input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? "email-error" : undefined}
/>3.3.2 Labels or Instructions (Level A)
Form fields must have clear labels:
// ✅ Associated label
<label htmlFor="username">Username</label>
<input id="username" type="text" />
// ✅ Aria-label when visual label not present
<input type="search" aria-label="Search products" />3.3.3 Error Suggestion (Level AA)
Provide correction suggestions when possible:
{error === 'EMAIL_INVALID' && (
<p id="email-error" role="alert">
Please enter a valid email address (example: user@example.com)
</p>
)}3.3.4 Error Prevention (Legal, Financial, Data) (Level AA)
Provide confirmation before critical actions:
<Dialog.Root open={showConfirm} onOpenChange={setShowConfirm}>
<Dialog.Trigger asChild>
<button>Delete account</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content>
<Dialog.Title>Confirm deletion</Dialog.Title>
<Dialog.Description>
This action cannot be undone. Are you sure?
</Dialog.Description>
<button onClick={handleDelete}>Yes, delete</button>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>Principle 4: Robust
Content must be robust enough to be interpreted by assistive technologies.
4.1 Compatible
4.1.2 Name, Role, Value (Level A)
All UI components must have proper name, role, and value:
// ✅ Native button (implicit role)
<button aria-label="Close dialog" onClick={onClose}>
×
</button>
// ✅ Custom widget with explicit role
<div
role="switch"
aria-checked={isEnabled}
aria-label="Enable notifications"
tabIndex={0}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') handleToggle();
}}
/>
// ❌ Missing accessible name
<button onClick={onClose}>×</button>4.1.3 Status Messages (Level AA)
Status updates must be programmatically determinable:
// ✅ Live region for status updates
<div role="status" aria-live="polite">
{items.length} items in cart
</div>
// ✅ Alert for errors
<div role="alert" aria-live="assertive">
{error}
</div>Testing Tools
| Tool | Purpose | Link |
|---|---|---|
| axe DevTools | Automated testing | Browser extension |
| WebAIM Contrast Checker | Color contrast | webaim.org/resources/contrastchecker |
| WAVE | Page-level audit | wave.webaim.org |
| NVDA | Screen reader (Windows) | nvaccess.org |
| JAWS | Screen reader (Windows) | freedomscientific.com |
| VoiceOver | Screen reader (macOS/iOS) | Built-in |
| TalkBack | Screen reader (Android) | Built-in |
Resources
- WCAG 2.2 Official Spec
- MDN Accessibility Guide
- WebAIM Resources
- A11y Project Checklist
- Inclusive Components
Version: 1.0.0 Last Updated: 2026-01-16 Based on: WCAG 2.2 Level AA
Checklists (3)
Focus Checklist
Focus Management Checklist
Comprehensive checklist for implementing and verifying focus management in React applications.
Implementation Checklist
Modal/Dialog Focus Trapping
- Modal contains all focusable elements within a container
- First focusable element receives focus when modal opens
- Tab key moves focus forward within the modal
- Shift+Tab moves focus backward within the modal
- Focus wraps from last to first element and vice versa
- Focus cannot escape the modal via Tab/Shift+Tab
- Escape key closes the modal
- Focus returns to trigger element when modal closes
- Modal has
role="dialog"andaria-modal="true" - Modal is wrapped with
AnimatePresencefor exit animations
Roving Tabindex (Toolbars/Menus)
- Only one item in the group has
tabindex="0"(the active item) - All other items have
tabindex="-1" - Arrow keys move focus between items
- Arrow key direction matches orientation (horizontal/vertical)
- Home key focuses the first item
- End key focuses the last item
- Tab key moves out of the group
- Active item updates when focused with mouse
- Visual focus indicator is visible on active item
Skip Links
- Skip link is the first focusable element on the page
- Skip link is visually hidden by default
- Skip link becomes visible when focused
- Skip link text clearly describes the destination ("Skip to main content")
- Target element has
idmatching skip linkhref - Target element has
tabIndex=\{-1\}for programmatic focus - Skip links are styled with sufficient contrast and size
- Skip links are tested with keyboard navigation
Form Focus Management
- First invalid field receives focus after validation
- Success message receives focus after form submission
- Error messages are announced to screen readers
- Multi-step forms maintain focus context between steps
- Autofocus is used sparingly and intentionally
- Focus is not lost when dynamically adding/removing fields
Focus Indicators
- All interactive elements have a visible focus indicator
- Focus indicator has sufficient contrast (3:1 minimum)
- Focus indicator is not removed globally with CSS
- Use
:focus-visibleto show indicator only for keyboard users - Focus indicator is animated smoothly (optional)
- Custom focus styles match the design system
Testing Checklist
Manual Keyboard Testing
- Navigate entire UI using only keyboard (no mouse)
- Verify all interactive elements are reachable
- Check that focus order follows visual/logical order
- Test Tab, Shift+Tab, Arrow keys, Enter, Escape, Space
- Verify focus doesn't get stuck in any component
- Check that focus wraps correctly in modal/roving tabindex
- Verify focus returns to trigger after closing modal/menu
- Test with screen reader (NVDA, JAWS, VoiceOver)
Automated Testing (Playwright/Vitest)
- Test focus trap in modal (
toBeFocused()) - Test roving tabindex with arrow keys
- Test skip link navigation
- Test focus restoration after modal close
- Test Escape key closes modal and restores focus
- Test focus moves to first error after form validation
- Test focus moves to confirmation message after success
- Test focus order matches DOM order
Screen Reader Testing
NVDA (Windows)
- Focus announces element role and label
- Modal announces "dialog" role and title
- Error messages are announced immediately
- Form fields announce label, role, and validation state
- Skip links are announced and functional
VoiceOver (macOS)
- Focus announces element role and label
- Modal announces "dialog" role and title
- Error messages are announced immediately
- Form fields announce label, role, and validation state
- Skip links are announced and functional
JAWS (Windows)
- Focus announces element role and label
- Modal announces "dialog" role and title
- Error messages are announced immediately
- Form fields announce label, role, and validation state
- Skip links are announced and functional
Visual Focus Indicator Testing
- Focus indicator is visible on all interactive elements
- Focus indicator has sufficient contrast (3:1 minimum)
- Focus indicator is not hidden by other elements
- Focus indicator respects color scheme (light/dark mode)
- Focus indicator works with design system tokens
Cross-Browser Testing
- Chrome: Focus indicator visible, keyboard navigation works
- Firefox: Focus indicator visible, keyboard navigation works
- Safari: Focus indicator visible, keyboard navigation works
- Edge: Focus indicator visible, keyboard navigation works
Debugging Checklist
Focus Lost or Not Visible
- Check if element has
tabindex="-1"(not keyboard focusable) - Check if element is hidden (
display: none,visibility: hidden) - Check if element is outside viewport
- Check if CSS removes outline/focus indicator
- Use browser DevTools to track
document.activeElement
Focus Trap Not Working
- Verify focusable selector includes all interactive elements
- Check if event listener is attached to correct container
- Verify
event.preventDefault()is called on Tab key - Check if first/last element references are correct
- Use
console.logto debug focus trap logic
Roving Tabindex Not Working
- Verify only one item has
tabindex="0"at a time - Check if arrow key event listener is attached
- Verify
setActiveIndexupdates correctly - Check if orientation matches key bindings
- Use React DevTools to inspect state updates
Focus Restoration Not Working
- Verify trigger element is stored in ref
- Check if ref is cleared after modal closes
- Verify element still exists in DOM when refocused
- Check if element is focusable (not disabled/hidden)
- Use
console.logto track trigger ref lifecycle
Code Review Checklist
- All modals/dialogs implement focus trap
- Toolbars/menus use roving tabindex pattern
- Skip links are present on all pages
- Form validation focuses first error
- Success/error messages receive focus
- No global
outline: nonein CSS -
:focus-visibleis used instead of:focuswhere appropriate - Focus management hooks are reusable and tested
- ARIA attributes are correct (
role,aria-modal,aria-label) - Focus restoration is implemented for all dismissible UI
Accessibility Compliance
- WCAG 2.1 Level AA: Focus Visible (2.4.7)
- WCAG 2.1 Level AA: Focus Order (2.4.3)
- WCAG 2.1 Level AA: Keyboard (2.1.1)
- WCAG 2.1 Level AA: No Keyboard Trap (2.1.2)
- WCAG 2.1 Level AAA: Focus Appearance (2.4.13) (optional)
Last Updated: 2026-01-16
React Aria Checklist
React Aria Component Checklist
Comprehensive checklist for building accessible components with React Aria.
Pre-Implementation
Before building a new component:
- Identify the correct ARIA pattern from ARIA Authoring Practices Guide
- Determine if a React Aria hook exists for this pattern
- Install dependencies:
npm install react-aria react-stately - Review existing examples in React Aria documentation
ARIA Roles and Attributes
Ensure proper semantic structure:
- Component uses appropriate ARIA role (button, dialog, listbox, menu, etc.)
- All interactive elements have accessible names (
aria-labelor associated<label>) - Related elements are linked with
aria-labelledby,aria-describedby,aria-controls - Dynamic content uses
aria-liveregions (polite, assertive, off) - Hidden elements use
aria-hidden="true"(NOT display: none for SR-only content) - States are announced:
aria-expanded,aria-selected,aria-checked,aria-pressed - Invalid inputs have
aria-invalid="true"andaria-errormessage
Keyboard Navigation
All interactions must be keyboard accessible:
Focus Management
- All interactive elements are focusable (no
tabindex="-1"on buttons/links) - Focus order follows visual order (logical tab sequence)
- Custom components have appropriate
tabIndex(0 for focusable, -1 for managed) - Focus indicators are visible (use
useFocusRingfor keyboard-only indicators) - No keyboard traps (user can always escape with Tab or Escape)
Modal/Overlay Focus
- Focus is trapped within modal using
<FocusScope contain> - Focus auto-moves to first focusable element on open (
autoFocus) - Focus restores to trigger element on close (
restoreFocus) - Escape key closes modal/overlay
- Clicking outside dismisses (if
isDismissableis true)
Keyboard Shortcuts
- Enter/Space - Activates buttons and toggles
- Arrow keys - Navigate lists, menus, tabs, radio groups
- Home/End - Jump to first/last item in lists/menus
- Escape - Closes overlays, cancels actions
- Tab/Shift+Tab - Moves focus between interactive elements
- Type-ahead - Single-character search in listboxes/menus (if applicable)
Screen Reader Testing
Test with actual screen readers:
NVDA (Windows) + Chrome/Firefox
- Navigate component with Tab/Shift+Tab
- Verify all elements are announced correctly
- Test forms mode (Enter on input fields)
- Verify
aria-liveannouncements work
VoiceOver (macOS) + Safari
- Navigate with VO+Right Arrow (browse mode)
- Test form controls with VO+Space
- Verify rotor navigation (VO+U)
- Check landmarks and headings structure
JAWS (Windows) + Chrome/Edge
- Test virtual cursor navigation
- Verify forms mode activation
- Test table navigation (if applicable)
Mobile Screen Readers
- TalkBack (Android) - Swipe navigation
- VoiceOver (iOS) - Swipe navigation
- Test touch gestures (double-tap to activate)
Common Patterns Checklist
Button Component
- Uses
useButtonhook, not div+onClick - Supports
onPressfor click/tap/Enter/Space - Has
isPressedstate for visual feedback - Uses
useFocusRingfor keyboard focus indicator - Works with
isDisabledprop (no pointer events, aria-disabled)
Dialog/Modal Component
- Uses
useDialog+useModalOverlayhooks - Wrapped in
<FocusScope contain restoreFocus autoFocus> - Has accessible name (
aria-labeloraria-labelledby) - Escape key closes modal
- Clicking overlay dismisses (if
isDismissable) - Uses
useOverlayTriggerStatefor open/close state
Combobox/Autocomplete
- Uses
useComboBox+useComboBoxState - Label associated with input
- Dropdown opens on input focus or button click
- Arrow keys navigate options
- Enter selects option
- Escape closes dropdown
- Type-ahead filtering works
- Selected value shown in input
-
aria-expandedindicates dropdown state
Menu Component
- Uses
useMenu+useMenuItemhooks - Trigger button has
aria-haspopup="menu" - Arrow keys navigate items (roving tabindex)
- Enter/Space activates menu item
- Escape closes menu
- Focus returns to trigger button on close
- Submenus open with Arrow Right, close with Arrow Left
ListBox Component
- Uses
useListBox+useOptionhooks - Supports single/multiple selection modes
- Arrow keys navigate options
- Enter/Space toggles selection
- Home/End jump to first/last item
- Selected items have
aria-selected="true" - Type-ahead search works
Form Field Component
- Uses
useTextField,useCheckbox,useRadioGroup, etc. - Label visually and programmatically associated
- Required fields have
aria-required="true" - Error messages linked with
aria-describedby - Invalid inputs have
aria-invalid="true" - Helper text announced to screen readers
Testing Strategy
Automated Testing
- Add
jest-axetests for automatic WCAG violations - Use
@testing-library/reactfor interaction testing - Test keyboard navigation programmatically
- Verify ARIA attributes with queries
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('MyComponent has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Manual Testing
- Tab through entire component (forward and backward)
- Test with keyboard only (no mouse)
- Use screen reader to verify announcements
- Test with browser zoom at 200%
- Verify color contrast with devtools
- Test in high contrast mode (Windows)
Performance Considerations
- Large lists use virtualization (
@tanstack/react-virtual) - Focus management doesn't cause layout thrashing
-
mergePropsused instead of manual object spreading - State updates debounced/throttled where appropriate (search inputs)
Documentation
- Component usage examples in Storybook/docs
- Keyboard shortcuts documented
- ARIA attributes explained
- Common gotchas and troubleshooting
Code Review Checklist
Before merging:
- No div+onClick buttons (use
useButtoninstead) - No manual focus management for modals (use
FocusScope) - All interactive elements keyboard accessible
- Proper ARIA roles and attributes
- Focus indicators visible
- Screen reader tested
- jest-axe tests pass
- TypeScript types correct (from
react-ariatypes)
Resources
- React Aria Documentation
- ARIA Authoring Practices Guide
- WebAIM Screen Reader Testing
- WCAG 2.1 Guidelines
Wcag Checklist
WCAG 2.2 AA Compliance Checklist
Use this checklist to audit components and pages for accessibility compliance.
Quick Pre-Flight Checklist
Before submitting any component for review:
- All interactive elements are keyboard accessible
- Focus indicators are visible (3px solid outline)
- Color contrast meets 4.5:1 for text, 3:1 for UI components
- All images have alt text (or alt="" if decorative)
- Form inputs have associated labels
- Error messages use
role="alert"andaria-describedby - Interactive elements are at least 24x24px
- No positive
tabIndexvalues (e.g., tabIndex={5}) - Semantic HTML used (button, nav, main, article)
- Tested with keyboard navigation (Tab, Enter, Esc)
Detailed Audit Checklist
Perceivable (Can users perceive the content?)
1.1 Text Alternatives
- All
<img>elements havealtattribute - Decorative images use
alt=""orrole="presentation" - Icon buttons have
aria-label - Complex images (charts, diagrams) have detailed descriptions
- SVG icons inside buttons have
aria-hidden="true"
1.3 Adaptable
- Semantic HTML used (
<header>,<nav>,<main>,<article>,<footer>) - Heading hierarchy is correct (h1 → h2 → h3, no skipping levels)
- Form fields use
<label>elements withhtmlForattribute - Related form fields grouped with
<fieldset>and<legend> - Lists use
<ul>,<ol>, or<dl>(not div + CSS) - Tables use
<th>withscopeattribute - Form inputs have
autoCompleteattributes
1.4 Distinguishable
- Text contrast ratio ≥ 4.5:1 (normal text) or ≥ 3:1 (large text)
- UI component contrast ≥ 3:1 (borders, icons, focus indicators)
- Information not conveyed by color alone (use icons + text)
- Focus indicators have ≥ 3:1 contrast against background
- No horizontal scrolling at 400% zoom (320px width)
- Content reflows at 320px width without loss of information
- No loss of content when text spacing increased (line-height: 1.5)
Tools:
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
- Chrome DevTools: Inspect > Color picker > Contrast ratio
Operable (Can users operate the interface?)
2.1 Keyboard Accessible
- All interactive elements reachable via keyboard
- All actions available via keyboard (no mouse-only interactions)
- No keyboard traps (can exit all components with Esc or Tab)
- Keyboard shortcuts require modifier key (Ctrl/Cmd/Alt)
- Custom widgets handle Enter and Space keys
Test: Tab through entire page, press Enter/Space on all interactive elements
2.4 Navigable
- Skip link provided to bypass repeated navigation
- Page has unique
<title>element - Link text is descriptive (not "click here")
- Focus order follows visual reading order (left-to-right, top-to-bottom)
- Focus visible on all interactive elements
- Headings describe topic or purpose
- Multiple ways to find pages (menu, search, sitemap)
- Current page indicated in navigation
- Focus not obscured by sticky headers/footers (use
scroll-margin-top)
Test: Tab through page, verify logical sequence
2.5 Input Modalities
- All multipoint gestures have single-pointer alternatives (buttons)
- All dragging functionality has keyboard alternative
- Interactive elements ≥ 24x24px (or 44x44px for touch devices)
- Adequate spacing between interactive elements (8px minimum)
- Click actions complete on mouse up (not mouse down)
- Accessible name includes visible label text
Test: Resize browser to 320px, verify tap target sizes
Understandable (Can users understand the content and interface?)
3.1 Readable
- Page language specified (
<html lang="en">) - Language changes marked with
langattribute
3.2 Predictable
- Focus does not trigger automatic navigation or form submission
- Input does not cause unexpected context changes
- Navigation consistent across pages
- Repeated components identified consistently
3.3 Input Assistance
- Form fields have visible labels
- Required fields indicated (with
aria-required="true") - Error messages clearly identify which field has error
- Error messages provide correction suggestions
- Errors use
role="alert"for screen reader announcement - Form fields use
aria-invalid="true"when errors present - Form fields use
aria-describedbyto link to error messages - Critical actions require confirmation (delete, purchase, submit)
Test: Submit form with errors, verify error messages are announced
Robust (Can assistive technologies interpret the content?)
4.1 Compatible
- HTML validates (no duplicate IDs, proper nesting)
- ARIA roles used correctly (match semantic HTML when possible)
- All custom widgets have proper
roleattribute - All interactive elements have accessible names
- State changes programmatically determinable (
aria-expanded,aria-checked) - Status messages use
role="status"orrole="alert" - Live regions used for dynamic content updates
Test: Run axe DevTools, validate with W3C validator
Screen Reader Testing Checklist
Test with at least one screen reader:
Windows
- NVDA (free) - nvaccess.org
- JAWS (commercial) - freedomscientific.com
macOS/iOS
- VoiceOver (built-in) - Cmd+F5 to enable
Android
- TalkBack (built-in)
Verification Steps
- Navigate with Tab key, verify focus indicators
- Navigate with arrow keys (for custom widgets)
- Verify all images/icons are announced correctly
- Verify form labels are announced
- Verify error messages are announced
- Verify dynamic content updates are announced
- Verify headings provide proper page structure
- Verify links are descriptive when read out of context
- Verify button purposes are clear
Automated Testing Checklist
Run these tools before manual testing:
- axe DevTools browser extension - catches 30-50% of issues
- Lighthouse accessibility audit - built into Chrome DevTools
- WAVE browser extension - visual feedback on accessibility issues
- ESLint jsx-a11y plugin - catches issues during development
- Playwright accessibility tests - automated regression tests
Example Playwright test:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('should not have any automatically detectable accessibility issues', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});Common Issues and Fixes
| Issue | Fix |
|---|---|
| Missing alt text | Add alt attribute to all images |
| Low contrast | Darken text or lighten background to meet 4.5:1 ratio |
| Missing label | Add <label> with htmlFor or aria-label |
| Keyboard trap | Add onKeyDown handler to detect Esc key |
| No focus indicator | Add :focus-visible \{ outline: 3px solid #0052cc; \} |
| Div button | Replace with <button> or add role="button" + keyboard handler |
| Empty link | Add descriptive text or aria-label |
| Small touch target | Increase min-width and min-height to 24px (44px for touch) |
| Non-semantic HTML | Replace divs with <nav>, <main>, <article>, <button>, etc. |
| Color-only status | Add icon or text label alongside color |
Component-Specific Checklists
Button Component
- Uses
<button>element (not div with onClick) - Has accessible name (visible text or aria-label)
- Disabled state uses
disabledattribute (not just CSS) - Icon-only buttons have
aria-label - Minimum 24x24px size
- Focus indicator visible
Form Component
- All inputs have associated labels
- Required fields marked with
aria-required="true" - Error messages use
role="alert" - Error messages linked with
aria-describedby - Invalid fields marked with
aria-invalid="true" - Submit button clearly labeled
- Fieldsets group related inputs
Modal/Dialog Component
- Uses
<dialog>element or proper ARIA roles - Focus trapped within modal when open
- Focus returns to trigger element when closed
- Closes on Esc key
- Backdrop closes modal on click
- First focusable element receives focus on open
- Title uses
<h2>oraria-labelledby
Navigation Component
- Uses
<nav>landmark - Skip link provided
- Current page indicated with
aria-current="page" - Keyboard accessible
- Links descriptive
- Dropdown menus keyboard accessible (arrow keys)
Data Table Component
- Uses
<table>,<thead>,<tbody>,<th>,<td>elements - Header cells use
<th>withscopeattribute - Complex tables have
<caption>oraria-label - Sortable columns indicate sort direction with
aria-sort - Expandable rows use
aria-expanded
Compliance Sign-Off
Component/Page: ____________________
- Automated tests passed (axe, Lighthouse, WAVE)
- Manual keyboard testing completed
- Screen reader testing completed
- Color contrast verified
- Semantic HTML used throughout
- All checklist items above verified
Reviewed by: ____________________ Date: ____________________
Version: 1.0.0 Last Updated: 2026-01-16 Based on: WCAG 2.2 Level AA
Examples (3)
Focus Examples
Focus Management Examples
Complete, production-ready code examples for focus management patterns.
Example 1: Focus Trap Hook
A reusable hook for modal/dialog focus trapping.
import { useEffect, useRef, useCallback } from 'react';
const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
const containerRef = useRef<T>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Store the element that triggered the modal
useEffect(() => {
if (isActive) {
previousActiveElement.current = document.activeElement as HTMLElement;
} else if (previousActiveElement.current) {
previousActiveElement.current.focus();
previousActiveElement.current = null;
}
}, [isActive]);
// Trap focus within the container
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (!isActive || event.key !== 'Tab') return;
const container = containerRef.current;
if (!container) return;
const focusableElements = Array.from(
container.querySelectorAll(FOCUSABLE_SELECTOR)
) as HTMLElement[];
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab: wrap to last element
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: wrap to first element
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}, [isActive]);
// Focus the first element when activated
useEffect(() => {
if (!isActive) return;
const container = containerRef.current;
if (!container) return;
const focusableElements = Array.from(
container.querySelectorAll(FOCUSABLE_SELECTOR)
) as HTMLElement[];
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}, [isActive]);
// Attach event listener
useEffect(() => {
if (!isActive) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isActive, handleKeyDown]);
return containerRef;
}Usage:
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { AnimatePresence, motion } from 'motion/react';
import { modalBackdrop, modalContent } from '@/lib/animations';
function Modal({ isOpen, onClose, children }) {
const containerRef = useFocusTrap<HTMLDivElement>(isOpen);
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
{...modalBackdrop}
className="fixed inset-0 z-50 bg-black/50"
onClick={onClose}
/>
<motion.div
{...modalContent}
ref={containerRef}
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
{children}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}Example 2: Roving Tabindex Component
A toolbar with arrow key navigation.
import { useRef, useState, useCallback } from 'react';
type Orientation = 'horizontal' | 'vertical';
export function useRovingTabindex<T extends HTMLElement>(
itemCount: number,
orientation: Orientation = 'vertical'
) {
const [activeIndex, setActiveIndex] = useState(0);
const itemsRef = useRef<Map<number, T>>(new Map());
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
const keys = orientation === 'horizontal'
? { next: 'ArrowRight', prev: 'ArrowLeft' }
: { next: 'ArrowDown', prev: 'ArrowUp' };
let nextIndex: number | null = null;
if (event.key === keys.next) {
nextIndex = (activeIndex + 1) % itemCount;
} else if (event.key === keys.prev) {
nextIndex = (activeIndex - 1 + itemCount) % itemCount;
} else if (event.key === 'Home') {
nextIndex = 0;
} else if (event.key === 'End') {
nextIndex = itemCount - 1;
}
if (nextIndex !== null) {
event.preventDefault();
setActiveIndex(nextIndex);
itemsRef.current.get(nextIndex)?.focus();
}
}, [activeIndex, itemCount, orientation]);
const getItemProps = useCallback((index: number) => ({
ref: (element: T | null) => {
if (element) {
itemsRef.current.set(index, element);
} else {
itemsRef.current.delete(index);
}
},
tabIndex: index === activeIndex ? 0 : -1,
onFocus: () => setActiveIndex(index),
}), [activeIndex]);
return {
activeIndex,
setActiveIndex,
handleKeyDown,
getItemProps,
};
}Usage: Toolbar
function Toolbar() {
const { getItemProps, handleKeyDown } = useRovingTabindex<HTMLButtonElement>(
3,
'horizontal'
);
return (
<div role="toolbar" aria-label="Text formatting" onKeyDown={handleKeyDown}>
<button {...getItemProps(0)} aria-label="Bold">
<BoldIcon />
</button>
<button {...getItemProps(1)} aria-label="Italic">
<ItalicIcon />
</button>
<button {...getItemProps(2)} aria-label="Underline">
<UnderlineIcon />
</button>
</div>
);
}Usage: Vertical Menu
function Menu() {
const items = ['Profile', 'Settings', 'Logout'];
const { getItemProps, handleKeyDown } = useRovingTabindex<HTMLButtonElement>(
items.length,
'vertical'
);
return (
<div role="menu" onKeyDown={handleKeyDown}>
{items.map((item, index) => (
<button
key={item}
role="menuitem"
{...getItemProps(index)}
>
{item}
</button>
))}
</div>
);
}Example 3: Focus Restore Utility
Utility for restoring focus after navigation or modal close.
import { useEffect, useRef } from 'react';
export function useFocusRestore(shouldRestore: boolean) {
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
// Store current focus when component mounts
previousFocusRef.current = document.activeElement as HTMLElement;
return () => {
// Restore focus when component unmounts (if flag is true)
if (shouldRestore && previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}, [shouldRestore]);
}Usage: Modal with Focus Restore
function ConfirmationModal({ isOpen, onClose }) {
useFocusRestore(isOpen);
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2>Are you sure?</h2>
<button onClick={onClose}>Cancel</button>
<button onClick={handleConfirm}>Confirm</button>
</Modal>
);
}Advanced: Focus First Error in Form
import { useEffect, useRef } from 'react';
export function useFocusFirstError(errors: Record<string, string>) {
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (Object.keys(errors).length === 0) return;
const firstErrorField = Object.keys(errors)[0];
const element = formRef.current?.querySelector(
`[name="${firstErrorField}"]`
) as HTMLElement;
element?.focus();
}, [errors]);
return formRef;
}Usage:
function MyForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const formRef = useFocusFirstError(errors);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateForm();
setErrors(validationErrors);
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="email" type="email" aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email}</span>}
<input name="password" type="password" aria-invalid={!!errors.password} />
{errors.password && <span role="alert">{errors.password}</span>}
<button type="submit">Submit</button>
</form>
);
}Example 4: Skip Link Component
Accessible skip links for keyboard navigation.
import { motion } from 'motion/react';
import { fadeIn } from '@/lib/animations';
export function SkipLinks() {
return (
<nav aria-label="Skip links" className="sr-only focus-within:not-sr-only">
<motion.a
{...fadeIn}
href="#main-content"
className="skip-link"
>
Skip to main content
</motion.a>
<motion.a
{...fadeIn}
href="#navigation"
className="skip-link"
>
Skip to navigation
</motion.a>
</nav>
);
}CSS (Tailwind):
/* Add to global styles */
.skip-link {
@apply fixed top-0 left-0 z-[9999] bg-primary text-white px-4 py-2 transform -translate-y-full;
@apply focus:translate-y-0 transition-transform;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.focus-within\:not-sr-only:focus-within {
position: static;
width: auto;
height: auto;
padding: 0;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
}Usage in Layout:
import { SkipLinks } from '@/components/SkipLinks';
export function Layout({ children }) {
return (
<>
<SkipLinks />
<nav id="navigation" aria-label="Main navigation">
{/* navigation */}
</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
);
}Example 5: Escape Key to Close
Utility hook for closing modals/menus with Escape key.
import { useEffect } from 'react';
export function useEscapeKey(onEscape: () => void, isActive: boolean = true) {
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onEscape();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onEscape, isActive]);
}Usage:
function Drawer({ isOpen, onClose }) {
useEscapeKey(onClose, isOpen);
return (
<AnimatePresence>
{isOpen && (
<motion.div {...slideInRight}>
<h2>Drawer Content</h2>
<button onClick={onClose}>Close</button>
</motion.div>
)}
</AnimatePresence>
);
}Example 6: Focus Within Detection
Detect when focus is inside a component (for styling/logic).
import { useEffect, useRef, useState } from 'react';
export function useFocusWithin<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [isFocusWithin, setIsFocusWithin] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleFocusIn = () => setIsFocusWithin(true);
const handleFocusOut = (e: FocusEvent) => {
// Check if focus moved outside the element
if (!element.contains(e.relatedTarget as Node)) {
setIsFocusWithin(false);
}
};
element.addEventListener('focusin', handleFocusIn);
element.addEventListener('focusout', handleFocusOut);
return () => {
element.removeEventListener('focusin', handleFocusIn);
element.removeEventListener('focusout', handleFocusOut);
};
}, []);
return { ref, isFocusWithin };
}Usage: Highlight Card on Focus Within
function Card({ title, children }) {
const { ref, isFocusWithin } = useFocusWithin<HTMLDivElement>();
return (
<div
ref={ref}
className={cn(
'p-4 rounded-lg border',
isFocusWithin ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
>
<h3>{title}</h3>
{children}
</div>
);
}Testing Example: Playwright
Test focus trap in a modal:
import { test, expect } from '@playwright/test';
test('modal traps focus correctly', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open Modal' }).click();
// Modal should be open
await expect(page.getByRole('dialog')).toBeVisible();
// First element should be focused
await expect(page.getByRole('button', { name: 'Close' })).toBeFocused();
// Tab to next element
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email')).toBeFocused();
// Tab to next element
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Submit' })).toBeFocused();
// Tab should wrap to first element
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Close' })).toBeFocused();
// Shift+Tab should wrap to last element
await page.keyboard.press('Shift+Tab');
await expect(page.getByRole('button', { name: 'Submit' })).toBeFocused();
// Escape should close modal and restore focus
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Open Modal' })).toBeFocused();
});Last Updated: 2026-01-16
React Aria Examples
React Aria Examples
Complete working examples of accessible components built with React Aria.
Installation
npm install react-aria react-stately
npm install --save-dev @types/react-aria @types/react-statelyExample 1: Accessible Dropdown Menu
Full-featured menu with keyboard navigation and ARIA semantics.
// MenuButton.tsx
import { useRef } from 'react';
import { useButton, useMenuTrigger, useMenu, useMenuItem, mergeProps } from 'react-aria';
import { useMenuTriggerState, useTreeState } from 'react-stately';
import { Item } from 'react-stately';
// Menu Trigger Component
export function MenuButton(props: { label: string; onAction: (key: string) => void }) {
const state = useMenuTriggerState({});
const ref = useRef<HTMLButtonElement>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
const { buttonProps } = useButton(menuTriggerProps, ref);
return (
<div className="relative inline-block">
<button
{...buttonProps}
ref={ref}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2"
>
{props.label}
<span aria-hidden="true">▼</span>
</button>
{state.isOpen && (
<MenuPopup
{...menuProps}
autoFocus={state.focusStrategy}
onClose={state.close}
onAction={(key) => {
props.onAction(key as string);
state.close();
}}
/>
)}
</div>
);
}
// Menu Popup Component
function MenuPopup(props: any) {
const ref = useRef<HTMLUListElement>(null);
const state = useTreeState({ ...props, selectionMode: 'none' });
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="absolute top-full left-0 mt-1 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg py-1 z-50"
>
{[...state.collection].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
onAction={props.onAction}
onClose={props.onClose}
/>
))}
</ul>
);
}
// Menu Item Component
function MenuItem({ item, state, onAction, onClose }: any) {
const ref = useRef<HTMLLIElement>(null);
const { menuItemProps, isFocused, isPressed } = useMenuItem(
{ key: item.key, onAction, onClose },
state,
ref
);
return (
<li
{...menuItemProps}
ref={ref}
className={`
px-4 py-2 cursor-pointer
${isFocused ? 'bg-blue-50' : ''}
${isPressed ? 'bg-blue-100' : ''}
`}
>
{item.rendered}
</li>
);
}
// Usage
function App() {
return (
<MenuButton
label="Actions"
onAction={(key) => {
if (key === 'edit') console.log('Edit clicked');
if (key === 'delete') console.log('Delete clicked');
}}
>
<Item key="edit">Edit</Item>
<Item key="delete">Delete</Item>
<Item key="duplicate">Duplicate</Item>
</MenuButton>
);
}Features:
- Keyboard navigation with arrow keys
- Enter/Space activates menu items
- Escape closes menu
- Focus returns to trigger button
- Proper ARIA roles and attributes
Example 2: Modal Dialog with Focus Trap
Accessible modal with focus management and backdrop dismissal.
// Modal.tsx
import { useRef } from 'react';
import { useDialog, useModalOverlay, useButton, FocusScope, mergeProps } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
import { AnimatePresence, motion } from 'motion/react';
import { modalBackdrop, modalContent } from '@/lib/animations';
// Modal Component
function Modal({
state,
title,
children,
}: {
state: ReturnType<typeof useOverlayTriggerState>;
title: string;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(
{ isDismissable: true },
state,
ref
);
const { dialogProps, titleProps } = useDialog({ 'aria-label': title }, ref);
return (
<AnimatePresence>
{state.isOpen && (
<>
{/* Backdrop */}
<motion.div
{...underlayProps}
{...modalBackdrop}
className="fixed inset-0 z-50 bg-black/50"
/>
{/* Modal Content */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<FocusScope contain restoreFocus autoFocus>
<motion.div
{...mergeProps(modalProps, dialogProps)}
{...modalContent}
ref={ref}
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 pointer-events-auto"
>
<h2 {...titleProps} className="text-xl font-semibold mb-4">
{title}
</h2>
{children}
</motion.div>
</FocusScope>
</div>
</>
)}
</AnimatePresence>
);
}
// Usage
function App() {
const state = useOverlayTriggerState({});
return (
<>
<button
onClick={state.open}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Open Modal
</button>
<Modal state={state} title="Confirm Action">
<p className="mb-4 text-gray-700">
Are you sure you want to proceed with this action?
</p>
<div className="flex gap-2 justify-end">
<button
onClick={state.close}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={() => {
console.log('Confirmed');
state.close();
}}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Confirm
</button>
</div>
</Modal>
</>
);
}Features:
- Focus trapped within modal
- Escape key closes modal
- Click outside dismisses
- Focus returns to trigger button
- Motion animations for smooth entrance/exit
Example 3: Combobox with Filtering
Autocomplete input with keyboard navigation and filtering.
// Combobox.tsx
import { useRef } from 'react';
import { useComboBox, useFilter, useButton } from 'react-aria';
import { useComboBoxState } from 'react-stately';
import { Item } from 'react-stately';
interface ComboBoxProps {
label: string;
items: Array<{ id: string; name: string }>;
onSelectionChange?: (key: string | null) => void;
}
export function ComboBox(props: ComboBoxProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const inputRef = useRef<HTMLInputElement>(null);
const listBoxRef = useRef<HTMLUListElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { inputProps, listBoxProps, labelProps } = useComboBox(
{
...props,
inputRef,
listBoxRef,
buttonRef,
},
state
);
const { buttonProps } = useButton(
{
onPress: () => state.open(),
isDisabled: state.isDisabled,
},
buttonRef
);
return (
<div className="relative inline-flex flex-col gap-1">
<label {...labelProps} className="font-medium text-sm">
{props.label}
</label>
<div className="flex">
<input
{...inputProps}
ref={inputRef}
className="flex-1 border rounded-l px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
{...buttonProps}
ref={buttonRef}
className="border border-l-0 rounded-r px-3 bg-gray-50 hover:bg-gray-100"
>
<span aria-hidden="true">▼</span>
</button>
</div>
{state.isOpen && (
<ul
{...listBoxProps}
ref={listBoxRef}
className="absolute top-full mt-1 w-full border bg-white rounded shadow-lg max-h-60 overflow-auto z-10"
>
{[...state.collection].map((item) => (
<ComboBoxItem key={item.key} item={item} state={state} />
))}
</ul>
)}
</div>
);
}
function ComboBoxItem({ item, state }: any) {
const ref = useRef<HTMLLIElement>(null);
const { optionProps, isSelected, isFocused } = useOption(
{ key: item.key },
state,
ref
);
return (
<li
{...optionProps}
ref={ref}
className={`
px-3 py-2 cursor-pointer
${isFocused ? 'bg-blue-50' : ''}
${isSelected ? 'bg-blue-100 font-semibold' : ''}
`}
>
{item.rendered}
</li>
);
}
// Usage
function App() {
const items = [
{ id: '1', name: 'Apple' },
{ id: '2', name: 'Banana' },
{ id: '3', name: 'Cherry' },
{ id: '4', name: 'Date' },
];
return (
<ComboBox
label="Select Fruit"
items={items}
onSelectionChange={(key) => console.log('Selected:', key)}
>
{(item) => <Item key={item.id}>{item.name}</Item>}
</ComboBox>
);
}Features:
- Type-ahead filtering with
useFilter - Keyboard navigation (arrow keys, Enter, Escape)
- Accessible name via label
- Button to open dropdown
- Selected value shown in input
Example 4: Tooltip Component
Accessible tooltip with hover/focus triggers.
// Tooltip.tsx
import { useRef } from 'react';
import { useTooltip, useTooltipTrigger } from 'react-aria';
import { useTooltipTriggerState } from 'react-stately';
import { AnimatePresence, motion } from 'motion/react';
import { fadeIn } from '@/lib/animations';
interface TooltipProps {
children: React.ReactElement;
content: string;
delay?: number;
}
export function Tooltip({ children, content, delay = 0 }: TooltipProps) {
const state = useTooltipTriggerState({ delay });
const ref = useRef<HTMLButtonElement>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{ isDisabled: false },
state,
ref
);
return (
<>
{/* Trigger element */}
<span {...triggerProps} ref={ref}>
{children}
</span>
{/* Tooltip popup */}
<AnimatePresence>
{state.isOpen && (
<TooltipPopup {...tooltipProps}>{content}</TooltipPopup>
)}
</AnimatePresence>
</>
);
}
function TooltipPopup(props: any) {
const ref = useRef<HTMLDivElement>(null);
const { tooltipProps } = useTooltip(props, ref);
return (
<motion.div
{...tooltipProps}
{...fadeIn}
ref={ref}
className="absolute z-50 px-3 py-1.5 bg-gray-900 text-white text-sm rounded shadow-lg"
style={{
top: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)',
}}
>
{props.children}
</motion.div>
);
}
// Usage
function App() {
return (
<div className="p-8">
<Tooltip content="This is a helpful tooltip">
<button className="px-4 py-2 bg-blue-500 text-white rounded">
Hover Me
</button>
</Tooltip>
</div>
);
}Features:
- Shows on hover and focus
- Accessible via
aria-describedby - Delay before showing (configurable)
- Motion animation for smooth entrance
Testing Example
Using @testing-library/react and jest-axe:
// MenuButton.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import { MenuButton } from './MenuButton';
import { Item } from 'react-stately';
expect.extend(toHaveNoViolations);
describe('MenuButton', () => {
test('has no accessibility violations', async () => {
const { container } = render(
<MenuButton label="Actions" onAction={() => {}}>
<Item key="edit">Edit</Item>
<Item key="delete">Delete</Item>
</MenuButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('opens menu on click', async () => {
const user = userEvent.setup();
render(
<MenuButton label="Actions" onAction={() => {}}>
<Item key="edit">Edit</Item>
</MenuButton>
);
const button = screen.getByRole('button', { name: /actions/i });
await user.click(button);
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /edit/i })).toBeInTheDocument();
});
test('navigates with arrow keys', async () => {
const user = userEvent.setup();
render(
<MenuButton label="Actions" onAction={() => {}}>
<Item key="edit">Edit</Item>
<Item key="delete">Delete</Item>
</MenuButton>
);
const button = screen.getByRole('button', { name: /actions/i });
await user.click(button);
const editItem = screen.getByRole('menuitem', { name: /edit/i });
expect(editItem).toHaveFocus();
await user.keyboard('{ArrowDown}');
const deleteItem = screen.getByRole('menuitem', { name: /delete/i });
expect(deleteItem).toHaveFocus();
});
test('closes menu on escape', async () => {
const user = userEvent.setup();
render(
<MenuButton label="Actions" onAction={() => {}}>
<Item key="edit">Edit</Item>
</MenuButton>
);
const button = screen.getByRole('button', { name: /actions/i });
await user.click(button);
expect(screen.getByRole('menu')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
});Resources
Wcag Examples
WCAG Compliance Code Examples
Complete, production-ready examples of accessible patterns.
1. Accessible Form with Validation
Full form with labels, error handling, and live region announcements.
import { useState } from 'react';
import { z } from 'zod';
const FormSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
agreeToTerms: z.boolean().refine((val) => val === true, {
message: 'You must agree to the terms',
}),
});
type FormData = z.infer<typeof FormSchema>;
export function AccessibleForm() {
const [formData, setFormData] = useState<FormData>({
email: '',
password: '',
agreeToTerms: false,
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const [submitStatus, setSubmitStatus] = useState<string>('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const result = FormSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: Partial<Record<keyof FormData, string>> = {};
result.error.issues.forEach((issue) => {
const field = issue.path[0] as keyof FormData;
fieldErrors[field] = issue.message;
});
setErrors(fieldErrors);
setSubmitStatus('Please correct the errors below');
return;
}
setErrors({});
setSubmitStatus('Form submitted successfully!');
// Submit form...
};
return (
<form onSubmit={handleSubmit} noValidate>
<h1>Create Account</h1>
{/* Status message - announced by screen readers */}
{submitStatus && (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="mb-4 p-3 rounded bg-blue-50 text-blue-900"
>
{submitStatus}
</div>
)}
{/* Email field */}
<div className="mb-4">
<label htmlFor="email" className="block mb-1 font-medium">
Email <span aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
autoComplete="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
className={`w-full px-3 py-2 border rounded ${
errors.email ? 'border-red-600' : 'border-gray-300'
}`}
/>
<p id="email-hint" className="text-sm text-gray-600 mt-1">
We'll never share your email
</p>
{errors.email && (
<p id="email-error" role="alert" className="text-red-600 text-sm mt-1">
{errors.email}
</p>
)}
</div>
{/* Password field */}
<div className="mb-4">
<label htmlFor="password" className="block mb-1 font-medium">
Password <span aria-label="required">*</span>
</label>
<input
type="password"
id="password"
name="password"
autoComplete="new-password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : 'password-hint'}
className={`w-full px-3 py-2 border rounded ${
errors.password ? 'border-red-600' : 'border-gray-300'
}`}
/>
<p id="password-hint" className="text-sm text-gray-600 mt-1">
Must be at least 8 characters
</p>
{errors.password && (
<p id="password-error" role="alert" className="text-red-600 text-sm mt-1">
{errors.password}
</p>
)}
</div>
{/* Checkbox */}
<div className="mb-4">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={formData.agreeToTerms}
onChange={(e) => setFormData({ ...formData, agreeToTerms: e.target.checked })}
aria-required="true"
aria-invalid={!!errors.agreeToTerms}
aria-describedby={errors.agreeToTerms ? 'terms-error' : undefined}
className="mt-1 w-5 h-5"
/>
<span>
I agree to the <a href="/terms" className="text-blue-600 underline">terms and conditions</a>
<span aria-label="required"> *</span>
</span>
</label>
{errors.agreeToTerms && (
<p id="terms-error" role="alert" className="text-red-600 text-sm mt-1">
{errors.agreeToTerms}
</p>
)}
</div>
{/* Submit button */}
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Create Account
</button>
</form>
);
}Key accessibility features:
- All inputs have associated labels with
htmlFor - Required fields marked with
aria-required="true" - Invalid fields marked with
aria-invalid="true" - Error messages use
role="alert"for immediate announcement - Error messages linked with
aria-describedby - Hint text linked with
aria-describedby - Status message uses
role="status"witharia-live="polite" - Visible focus indicators
- AutoComplete attributes for password managers
2. Accessible Modal Dialog
Modal with focus trap, Esc to close, and backdrop click handling.
import { useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { modalBackdrop, modalContent } from '@/lib/animations';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const triggerElementRef = useRef<HTMLElement | null>(null);
// Store the element that opened the modal
useEffect(() => {
if (isOpen) {
triggerElementRef.current = document.activeElement as HTMLElement;
}
}, [isOpen]);
// Focus trap and Esc key handler
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab') {
const modal = modalRef.current;
if (!modal) return;
const focusableElements = modal.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// Focus first element when modal opens
useEffect(() => {
if (isOpen && modalRef.current) {
const firstFocusable = modalRef.current.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
}, [isOpen]);
// Return focus to trigger element when modal closes
useEffect(() => {
if (!isOpen && triggerElementRef.current) {
triggerElementRef.current.focus();
triggerElementRef.current = null;
}
}, [isOpen]);
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
{...modalBackdrop}
className="fixed inset-0 z-50 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
{...modalContent}
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6"
>
{/* Title */}
<h2 id="modal-title" className="text-xl font-semibold mb-4">
{title}
</h2>
{/* Content */}
<div className="mb-6">{children}</div>
{/* Close button */}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Close
</button>
</div>
{/* Close icon button */}
<button
onClick={onClose}
aria-label="Close dialog"
className="absolute top-4 right-4 p-2 rounded hover:bg-gray-100 focus-visible:outline focus-visible:outline-2"
>
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}Key accessibility features:
role="dialog"andaria-modal="true"- Title linked with
aria-labelledby - Focus trapped within modal
- Esc key closes modal
- Focus returns to trigger element on close
- Close button has
aria-label - Backdrop click closes modal
- First focusable element receives focus on open
3. Skip Navigation Link
Allow keyboard users to bypass repeated navigation.
export function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
>
Skip to main content
</a>
);
}
// In your layout component:
export function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<SkipLink />
<header>
<nav>
{/* Navigation links */}
</nav>
</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
);
}/* styles/globals.css */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}4. Accessible Tab Component
Tabs with keyboard navigation (arrow keys, Home, End).
import { useState, useRef, useEffect } from 'react';
interface TabProps {
tabs: { id: string; label: string; content: React.ReactNode }[];
}
export function AccessibleTabs({ tabs }: TabProps) {
const [activeTab, setActiveTab] = useState(0);
const tabListRef = useRef<HTMLDivElement>(null);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let newIndex = index;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
e.preventDefault();
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = tabs.length - 1;
break;
default:
return;
}
setActiveTab(newIndex);
// Focus the new tab
const newTab = tabListRef.current?.children[newIndex] as HTMLElement;
newTab?.focus();
};
return (
<div>
{/* Tab list */}
<div
ref={tabListRef}
role="tablist"
aria-label="Content sections"
className="flex border-b border-gray-300"
>
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={`px-4 py-2 font-medium focus-visible:outline focus-visible:outline-2 ${
activeTab === index
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab panels */}
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== index}
className="p-4"
>
{tab.content}
</div>
))}
</div>
);
}Key accessibility features:
role="tablist",role="tab",role="tabpanel"aria-selectedindicates active tabaria-controlslinks tab to panel- Only active tab is focusable (
tabIndex=\{0\}) - Arrow keys navigate between tabs
- Home/End keys jump to first/last tab
- Panels hidden with
hiddenattribute (not CSS display:none)
5. Focus Management in Complex Widgets
Custom dropdown with roving tabindex.
import { useState, useRef, useEffect } from 'react';
interface DropdownProps {
label: string;
options: string[];
value: string;
onChange: (value: string) => void;
}
export function AccessibleDropdown({ label, options, value, onChange }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => (prev + 1) % options.length);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => (prev - 1 + options.length) % options.length);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) {
onChange(options[focusedIndex]);
setIsOpen(false);
buttonRef.current?.focus();
} else {
setIsOpen(true);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
}
};
// Focus first option when opening
useEffect(() => {
if (isOpen) {
setFocusedIndex(options.indexOf(value));
}
}, [isOpen, value, options]);
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
className="w-full px-4 py-2 text-left bg-white border border-gray-300 rounded focus-visible:outline focus-visible:outline-2"
>
<span id="dropdown-label" className="sr-only">{label}</span>
{value}
</button>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
onKeyDown={handleKeyDown}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-60 overflow-auto"
>
{options.map((option, index) => (
<li
key={option}
role="option"
aria-selected={option === value}
onClick={() => {
onChange(option);
setIsOpen(false);
buttonRef.current?.focus();
}}
className={`px-4 py-2 cursor-pointer ${
index === focusedIndex ? 'bg-blue-100' : ''
} ${option === value ? 'bg-blue-50 font-semibold' : ''}`}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}Key accessibility features:
role="listbox"androle="option"aria-haspopup="listbox"on triggeraria-expandedindicates open/closed statearia-selectedon current option- Arrow keys navigate options
- Enter/Space selects option
- Esc closes dropdown and returns focus
- Focus returns to trigger on close
6. Live Region for Dynamic Updates
Announce changes to screen reader users without interrupting.
import { useState, useEffect } from 'react';
export function ShoppingCart() {
const [items, setItems] = useState<string[]>([]);
const [statusMessage, setStatusMessage] = useState('');
const addItem = (item: string) => {
setItems([...items, item]);
setStatusMessage(`${item} added to cart. ${items.length + 1} items total.`);
};
const removeItem = (index: number) => {
const removedItem = items[index];
setItems(items.filter((_, i) => i !== index));
setStatusMessage(`${removedItem} removed from cart. ${items.length - 1} items total.`);
};
return (
<div>
<h2>Shopping Cart</h2>
{/* Live region for status updates */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{statusMessage}
</div>
{/* Visible cart count */}
<p aria-hidden="true">
{items.length} {items.length === 1 ? 'item' : 'items'} in cart
</p>
<ul>
{items.map((item, index) => (
<li key={index} className="flex justify-between items-center py-2">
<span>{item}</span>
<button
onClick={() => removeItem(index)}
aria-label={`Remove ${item} from cart`}
className="px-3 py-1 bg-red-600 text-white rounded"
>
Remove
</button>
</li>
))}
</ul>
<button
onClick={() => addItem('Product ' + (items.length + 1))}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Add Item
</button>
</div>
);
}Key accessibility features:
role="status"witharia-live="polite"announces changesaria-atomic="true"ensures entire message is read.sr-onlyclass hides visual duplicate- Remove buttons have descriptive
aria-label
7. Accessible Error Summary
Error summary at top of form that links to fields with errors.
interface ErrorSummaryProps {
errors: Record<string, string>;
}
export function ErrorSummary({ errors }: ErrorSummaryProps) {
const errorEntries = Object.entries(errors);
if (errorEntries.length === 0) return null;
return (
<div
role="alert"
aria-labelledby="error-summary-title"
className="mb-6 p-4 bg-red-50 border-l-4 border-red-600 rounded"
>
<h2 id="error-summary-title" className="text-lg font-semibold text-red-900 mb-2">
There {errorEntries.length === 1 ? 'is' : 'are'} {errorEntries.length}{' '}
{errorEntries.length === 1 ? 'error' : 'errors'} in this form
</h2>
<ul className="list-disc list-inside space-y-1">
{errorEntries.map(([field, message]) => (
<li key={field}>
<a
href={`#${field}`}
className="text-red-900 underline hover:text-red-700"
onClick={(e) => {
e.preventDefault();
document.getElementById(field)?.focus();
}}
>
{message}
</a>
</li>
))}
</ul>
</div>
);
}Key accessibility features:
role="alert"announces errors immediately- Links to fields with errors
- Clicking link focuses the field
- Descriptive error count
Resources
- WCAG 2.2 Spec
- WAI-ARIA Authoring Practices
- Radix UI Primitives - Accessible components
- Inclusive Components
- A11y Project Checklist
Version: 1.0.0 Last Updated: 2026-01-16
Skills Reference
Complete reference for all 104 OrchestKit skills.
Agent Orchestration
Agent orchestration patterns for agentic loops, multi-agent coordination, alternative frameworks, and multi-scenario workflows. Use when building autonomous agent loops, coordinating multiple agents, evaluating CrewAI/AutoGen/Swarm, or orchestrating complex multi-step scenarios.
Last updated on