accessibility-patterns
Accessibility Patterns
Semantic HTML First
Use the correct HTML element before reaching for ARIA. Native elements provide keyboard behavior, focus management, and screen reader semantics for free:
| Need | Use | Not |
|---|---|---|
| Button | <button> |
<div onClick> |
| Link | <a href> |
<span onClick> |
| Navigation | <nav> |
<div class="nav"> |
| List | <ul> / <ol> |
<div> with divs |
| Heading | <h1>–<h6> |
<div class="title"> |
| Form field | <input> / <select> / <textarea> |
<div contenteditable> |
| Dialog | <dialog> |
<div class="modal"> |
The first rule of ARIA: don't use ARIA if a native HTML element does the job.
ARIA Essentials
When native elements aren't sufficient:
Roles
Add semantic meaning to generic elements:
<div role="tablist">
<button role="tab" aria-selected="true">Tab 1</button>
<button role="tab" aria-selected="false">Tab 2</button>
</div>
<div role="tabpanel">Content</div>
States and Properties
Communicate dynamic state to assistive technology:
<button aria-expanded="false" aria-controls="menu-content">Menu</button>
<div id="menu-content" hidden>...</div>
<input aria-invalid="true" aria-describedby="error-msg" />
<span id="error-msg">Email is required</span>
Live Regions
Announce dynamic content changes:
<div aria-live="polite" aria-atomic="true">3 results found</div>
<div role="alert">Your session is about to expire.</div>
aria-live="polite"— announces after current speech finishes.aria-live="assertive"orrole="alert"— interrupts immediately (use sparingly).
Labels
Every interactive element needs an accessible name:
<!-- Visible label (preferred) -->
<label for="email">Email</label>
<input id="email" type="email" />
<!-- aria-label for icon-only buttons -->
<button aria-label="Close dialog"><CloseIcon /></button>
<!-- aria-labelledby for complex labels -->
<h2 id="section-title">Billing</h2>
<form aria-labelledby="section-title">...</form>
Keyboard Navigation
Focus Order
- Ensure logical tab order follows visual layout. Don't use
tabindex> 0. - Use
tabindex="0"to make non-interactive elements focusable when necessary. - Use
tabindex="-1"to make elements programmatically focusable but not in the tab order.
Key Handlers
Implement expected keyboard patterns for custom widgets:
| Widget | Keys |
|---|---|
| Button | Enter, Space to activate |
| Menu | Arrow keys to navigate, Enter to select, Escape to close |
| Tabs | Arrow keys to switch, Enter/Space to activate |
| Dialog | Escape to close, trap focus inside |
| Combobox | Arrow keys to navigate, Enter to select, Escape to close |
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusNextItem();
break;
case "ArrowUp":
e.preventDefault();
focusPreviousItem();
break;
case "Escape":
closeMenu();
break;
}
}
Focus Trapping
Modal dialogs must trap focus — Tab and Shift+Tab cycle only through elements inside the dialog:
function trapFocus(dialogRef: RefObject<HTMLDialogElement>) {
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const first = focusable?.[0];
const last = focusable?.[focusable.length - 1];
function handleTab(e: KeyboardEvent) {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
dialogRef.current?.addEventListener("keydown", handleTab);
}
Focus Restoration
When a dialog/popover closes, return focus to the element that triggered it:
const triggerRef = useRef<HTMLButtonElement>(null);
function openDialog() {
triggerRef.current = document.activeElement as HTMLButtonElement;
setOpen(true);
}
function closeDialog() {
setOpen(false);
triggerRef.current?.focus();
}
Color and Contrast
- Minimum contrast ratio: 4.5:1 for normal text, 3:1 for large text (WCAG AA).
- Never rely on color alone to convey meaning — add icons, text, or patterns.
- Test with forced-colors mode (Windows High Contrast).
Motion
Respect the user's motion preference:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
Forms
- Associate every input with a
<label>. - Group related fields with
<fieldset>and<legend>. - Display error messages inline, linked with
aria-describedby. - Use
aria-required="true"or therequiredattribute. - Mark invalid fields with
aria-invalid="true".
Images
<!-- Informative image -->
<img src="chart.png" alt="Sales increased 25% in Q4 2025" />
<!-- Decorative image -->
<img src="divider.svg" alt="" role="presentation" />
<!-- Complex image -->
<figure>
<img src="diagram.png" alt="System architecture overview" />
<figcaption>Request flow from client through API gateway to microservices</figcaption>
</figure>
Testing
- Keyboard-only: Tab through the entire page without a mouse.
- Screen reader: Test with VoiceOver (macOS), NVDA (Windows), or TalkBack (Android).
- Automated: Use
axe-core,eslint-plugin-jsx-a11y, or Lighthouse accessibility audit. - Manual: Zoom to 200%, use forced colors, test with
prefers-reduced-motion. - Testing Library: Use
getByRole,getByLabelText— if you can't query by role, the component may have an a11y issue.
More from grahamcrackers/skills
bulletproof-react-patterns
Bulletproof React architecture patterns for scalable, maintainable applications. Covers feature-based project structure, component patterns, state management boundaries, API layer design, error handling, security, and testing strategies. Use when structuring a React project, designing application architecture, organizing features, or when the user asks about React project structure or scalable patterns.
44react-aria-components
React Aria Components patterns for building accessible, unstyled UI with composition-based architecture. Covers component structure, styling with Tailwind and CSS, render props, collections, forms, selections, overlays, and drag-and-drop. Use when building accessible components, using react-aria-components, creating design systems, or when the user asks about React Aria, accessible UI primitives, or headless component libraries.
15clean-code-principles
Clean code principles for readable, maintainable TypeScript and React codebases. Covers naming, functions, abstraction, composition, error handling, comments, and code smells. Use when writing new code, refactoring, reviewing code quality, or when the user asks about clean code, readability, or maintainability.
10typescript-best-practices
Core TypeScript conventions for type safety, inference, and clean code. Use when writing TypeScript, reviewing TypeScript code, creating interfaces/types, or when the user asks about TypeScript patterns, conventions, or best practices.
9git-conventions
Git conventions for teams including conventional commits, branching strategies, PR workflows, merge strategies, and commit message formatting. Use when writing commit messages, creating branches, setting up Git workflows, or when the user asks about Git conventions, commit formats, branching strategies, or PR best practices.
7react-hook-form
React Hook Form patterns for performant, type-safe forms with Zod validation, field arrays, multi-step forms, and controlled components. Use when building forms, handling form validation, working with React Hook Form, or when the user asks about form patterns, field arrays, or form state management.
6