# What This React Accessibility Checklist Covers#
This React accessibility checklist is built for developers shipping production UI, not for audits that never make it into code. You will get concrete implementation guidance for ARIA, keyboard navigation, focus management, and testing, plus examples for forms, modals, menus, and toasts.
Accessibility matters because it reduces user friction and support overhead, improves conversion, and lowers legal risk. The World Health Organization estimates roughly 16 percent of the global population lives with a disability, and accessibility improvements often help everyone, including keyboard power users and mobile users.
You can treat this guide as a checklist during PR review and as a CI gate before releases. If you want to align this with broader engineering practices, pair it with a repeatable delivery pipeline like Web Development Process: Step-by-Step.
# Prerequisites#
| Requirement | Recommended | Notes |
|---|---|---|
| React | 18+ | Examples assume function components and hooks |
| Testing | Playwright | For keyboard and focus E2E checks |
| A11y engine | axe-core | For automated rule checks |
| Linting | eslint-plugin-jsx-a11y | Fast feedback in local dev |
| Basic forms knowledge | React Hook Form and Zod | Useful for forms section; see React Forms at Scale |
ℹ️ Note: Accessibility overlaps heavily with security and quality. If you are building internal admin panels or public SaaS, apply the same discipline to security controls as you do to a11y gates. Use Web Application Security Checklist as a parallel checklist.
# Checklist Overview#
Use this high-level checklist to guide implementation and review. The rest of the article expands each item with code and test examples.
| Area | Check | Why it matters |
|---|---|---|
| Semantics | Use native elements first | Built-in keyboard and screen reader support |
| ARIA | Add only when needed, and match expected behavior | Incorrect ARIA often makes UI worse |
| Keyboard | All interactive UI is usable without a mouse | Required for many users and power users |
| Focus | Visible focus, correct focus order, no traps | Prevents users from getting stuck |
| Forms | Proper labels, errors, and validation messages | Prevents abandonment and confusion |
| Modals | Focus trap, Escape close, restore focus | Avoids keyboard traps and context loss |
| Menus | Correct roles and arrow key behavior | Consistent navigation pattern |
| Toasts | Announce changes without hijacking focus | Users need feedback without interruption |
| Testing | axe plus Playwright plus small manual checks | Automation plus behavior coverage |
| CI | Run checks on PR and block regressions | Keeps a11y from drifting over time |
# 1) Semantic HTML First, Then ARIA#
Most React accessibility problems are caused by replacing native elements with generic containers. If you use correct elements, you get keyboard behavior, focus management, and accessible names by default.
Do this by default#
| Intent | Prefer | Avoid | Reason |
|---|---|---|---|
| Button action | button | div with onClick | button supports Enter and Space and has role |
| Navigation | nav with links | Clickable list items | Screen readers understand regions and links |
| Form inputs | label plus input | Placeholder-only label | Placeholder is not a label and disappears |
| Headings | h1 to h6 | Styled text | Heading structure powers navigation |
⚠️ Warning: Do not add
role="button"to adivand call it done. You still need keyboard handlers, focusability, and correct states. In most cases, replacing it with a realbuttonis faster and safer.
Accessible name rules you can rely on#
For most components, users need an accessible name that matches what they see. In practice, you should ensure one of the following is present:
- 1Visible label text associated with the element.
- 2
aria-labelfor icon-only controls. - 3
aria-labelledbypointing to visible text.
If you do not know what name your control has, inspect it in browser DevTools Accessibility panel.
# 2) Keyboard Navigation Checklist for React UI#
Keyboard accessibility is the easiest “real user” signal you can test quickly. If the UI works with Tab, Shift plus Tab, Enter, Space, Escape, and arrow keys where relevant, you avoid many a11y failures.
Keyboard checklist for PR review#
| Check | How to validate quickly | Common failure |
|---|---|---|
| All interactive elements reachable with Tab | Tab through the page | Clickable elements not focusable |
| Focus order matches visual order | Tab and observe highlight | CSS reordering causes confusing focus |
| Visible focus indicator | Tab and look for clear outline | Focus ring removed with CSS |
| No keyboard trap | Tab out of components | Focus stuck inside a widget |
| Escape closes transient UI | Open modal or menu, press Escape | Only click closes |
| Arrow keys behave in menus | Use arrow keys in menu | Tab cycles through menu items instead of arrows |
💡 Tip: Keep the browser focus ring. If design requires custom focus styling, use
:focus-visibleand do not remove outline without a replacement.
A minimal global CSS baseline that preserves usability:
/* Keep default focus, enhance only when keyboard is used */
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}# 3) Focus Management: The Rules That Prevent User Frustration#
Focus management is where React apps often break because rendering is dynamic. The user needs a predictable focus position after actions like opening a modal, submitting a form, or navigating.
Focus management rules of thumb#
| Scenario | Expected focus behavior | Implementation hint |
|---|---|---|
| Route change | Focus goes to page heading or main landmark | Focus main or h1 programmatically |
| Modal open | Focus moves into modal, usually first focusable | Focus trap library or custom trap |
| Modal close | Focus returns to the element that opened it | Store document.activeElement before open |
| Form submit error | Focus moves to error summary or first invalid field | Focus the first error input |
| Toast appears | Do not move focus | Use live regions instead |
A practical focus restore pattern#
Store the last focused element, then restore it on close.
import { useEffect, useRef } from "react";
export function useRestoreFocus(isOpen) {
const lastFocusedRef = useRef(null);
useEffect(() => {
if (isOpen) {
lastFocusedRef.current = document.activeElement;
} else if (lastFocusedRef.current && lastFocusedRef.current.focus) {
lastFocusedRef.current.focus();
}
}, [isOpen]);
}Use this hook in modals and popovers. It is small, predictable, and prevents the “where did my focus go” problem.
# 4) Forms: Labels, Errors, and Validation Messages#
Forms are the highest-impact a11y surface area in most products because they directly affect signup, checkout, and onboarding. The minimum standard is label association, meaningful error messages, and announcing validation changes.
If your forms are complex, tie this section with scalable patterns like React Forms at Scale: React Hook Form and Zod.
Form checklist#
| Item | Must-have behavior | Example approach |
|---|---|---|
| Labels | Each input has a programmatic label | label with htmlFor and input id |
| Required fields | Communicate required state | required plus visible indicator text |
| Errors | Error message is associated with the field | aria-describedby linking to error id |
| Invalid state | Screen readers know field is invalid | aria-invalid="true" when error exists |
| Error summary | Optional but recommended for long forms | List errors at top and move focus |
| Autocomplete | Use correct autocomplete tokens | autoComplete="email" and similar |
Concrete example: accessible input with error message#
export function TextField({ id, label, error, ...props }) {
const errorId = error ? `${id}-error` : undefined;
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={error ? "true" : "false"}
aria-describedby={errorId}
{...props}
/>
{error ? (
<p id={errorId}>{error}</p>
) : null}
</div>
);
}This covers the basics without overusing ARIA. The label provides the accessible name, and aria-describedby links the error text.
Error summary pattern for multi-step or long forms#
Error summaries reduce time-to-fix for keyboard and screen reader users because they provide a single navigation point.
Checklist for an error summary:
- 1Render summary only when errors exist.
- 2Give it a heading and a list of links to fields.
- 3Move focus to the summary on submit if there are errors.
import { useEffect, useRef } from "react";
export function ErrorSummary({ errors }) {
const ref = useRef(null);
const hasErrors = errors.length > 0;
useEffect(() => {
if (hasErrors) ref.current?.focus();
}, [hasErrors]);
if (!hasErrors) return null;
return (
<div tabIndex={-1} ref={ref} aria-labelledby="error-summary-title">
<h2 id="error-summary-title">Please fix the following</h2>
<ul>
{errors.map((e) => (
<li key={e.fieldId}>
<a href={`#${e.fieldId}`}>{e.message}</a>
</li>
))}
</ul>
</div>
);
}🎯 Key Takeaway: For forms, correct labeling and error association prevent silent failures where a screen reader user hears “edit text” but never hears what the field is or why it is invalid.
# 5) Modals: Dialog Semantics, Focus Trap, and Escape Close#
Modals are the most common source of keyboard traps. An accessible modal must behave like a temporary focus context, then return the user to where they were.
Modal checklist#
| Item | Requirement | Notes |
|---|---|---|
| Role | Use dialog semantics | Prefer native dialog when feasible |
| Label | Provide name and optionally description | aria-labelledby, aria-describedby |
| Focus | Move focus into modal on open | Usually first focusable element |
| Trap | Prevent focus from leaving modal | Use proven library if possible |
| Close | Escape closes and close button is focusable | Do not rely on backdrop only |
| Restore | Return focus to trigger | Use focus restore hook |
| Background | Hide background from assistive tech | Use aria-hidden or inert pattern |
Minimal React modal structure#
This example uses role="dialog" and aria-modal="true". It shows labeling and focus placement. For full focus trapping, consider a small dependency like react-focus-lock or a headless UI library that implements WAI-ARIA patterns correctly.
import { useEffect, useRef } from "react";
import { useRestoreFocus } from "./useRestoreFocus";
export function Modal({ isOpen, title, onClose, children }) {
const titleId = "modal-title";
const ref = useRef(null);
useRestoreFocus(isOpen);
useEffect(() => {
if (!isOpen) return;
const node = ref.current;
const first = node?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
first?.focus();
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div role="presentation">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={ref}
>
<h2 id={titleId}>{title}</h2>
<button type="button" onClick={onClose} aria-label="Close dialog">
Close
</button>
{children}
</div>
</div>
);
}⚠️ Warning: If you do not trap focus, users can Tab into the page behind the modal, which creates a confusing and often unusable experience. A correct focus trap is usually worth a dependency.
# 6) Menus: Roles, Roving Tabindex, and Arrow Keys#
Menus are not just styled lists. If you implement a menu as a composite widget, you must follow expected keyboard behavior. If you only need navigation links, a simple list of links is often more accessible than a full menu widget.
Decide what you are building first#
| UI | Use semantics | When |
|---|---|---|
| Navigation links | nav plus a | Site header, sidebar |
| Action menu | button plus menu widget | User avatar menu, overflow menu |
| Select-like choice list | select or combobox pattern | Forms and filters |
Action menu checklist#
| Item | Requirement | Example |
|---|---|---|
| Trigger | button with aria-haspopup="menu" | Indicates a popup menu |
| State | aria-expanded reflects open state | true when open |
| Menu role | Container uses role="menu" | For action menu pattern |
| Items | Each item uses role="menuitem" | Or menuitemcheckbox where needed |
| Keyboard | Arrow keys move between items | Roving tabindex |
| Close | Escape closes, click outside closes | Restore focus to trigger |
Roving tabindex example for menu items#
Keep Tab on the trigger, and use arrow keys inside the menu.
import { useEffect, useRef, useState } from "react";
export function ActionMenu({ items }) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const triggerRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
if (!open) return;
const node = menuRef.current?.querySelector('[role="menuitem"][tabindex="0"]');
node?.focus();
}, [open]);
const onKeyDown = (e) => {
if (!open) return;
if (e.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => (i + 1) % items.length);
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => (i - 1 + items.length) % items.length);
}
};
return (
<div onKeyDown={onKeyDown}>
<button
ref={triggerRef}
type="button"
aria-haspopup="menu"
aria-expanded={open ? "true" : "false"}
onClick={() => setOpen((v) => !v)}
>
Actions
</button>
{open ? (
<div role="menu" ref={menuRef} aria-label="Actions menu">
{items.map((item, idx) => (
<button
key={item.id}
type="button"
role="menuitem"
tabIndex={idx === activeIndex ? 0 : -1}
onFocus={() => setActiveIndex(idx)}
onClick={() => {
item.onSelect();
setOpen(false);
triggerRef.current?.focus();
}}
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}This is a simplified pattern. For production, you also need click-outside handling, positioning, and possibly typeahead navigation.
# 7) Toasts and Notifications: Announce Without Stealing Focus#
Toasts are transient UI. The accessibility goal is to announce the message to assistive technology without moving focus and without forcing the user to interact.
Toast checklist#
| Item | Requirement | Recommended ARIA |
|---|---|---|
| Do not move focus | User stays in current context | No focus calls on show |
| Announce message | Screen reader hears it | Live region |
| Error urgency | Errors should be more prominent | role="alert" or assertive live region |
| Dismiss button | If toast persists, it must be dismissible | Button with label |
| Timing | Avoid short auto-dismiss for critical messages | Keep errors until dismissed |
Accessible toast container with live region#
Use a live region wrapper. For most info toasts, aria-live="polite" is safer. For critical failures, use role="alert".
export function ToastRegion({ toasts }) {
return (
<div aria-live="polite" aria-relevant="additions text">
{toasts.map((t) => (
<div key={t.id} role={t.variant === "error" ? "alert" : undefined}>
<p>{t.message}</p>
{t.dismissible ? (
<button type="button" onClick={t.onDismiss} aria-label="Dismiss notification">
Dismiss
</button>
) : null}
</div>
))}
</div>
);
}💡 Tip: If your toast includes a link like “Undo”, keep it keyboard reachable but do not auto-focus it. Users should choose whether to interact.
# 8) ARIA Rules You Should Enforce in Code Review#
ARIA is powerful but easy to misuse. Treat these rules as a checklist item in every PR that introduces custom UI components.
ARIA do and do not table#
| Rule | Do | Do not |
|---|---|---|
| Prefer semantics | Use native elements when possible | Recreate button or input with div |
| Roles match behavior | role="menu" only if you implement menu keyboard patterns | Add roles without implementing interactions |
| Labels required | Provide accessible name for icon buttons | Ship unlabeled controls |
| States reflect reality | Keep aria-expanded synced | Hardcode state values |
| Avoid redundant ARIA | Let native semantics speak | Add role="button" to a real button |
Practical review questions:
- 1Does every interactive element have an accessible name that matches the UI text?
- 2If ARIA role is used, does keyboard behavior match user expectation for that role?
- 3Are
aria-*states controlled and updated correctly?
# 9) Automated Testing: axe in Unit and E2E, Plus Playwright Keyboard Tests#
Automated testing catches regressions early. In practice, you want:
- Component-level a11y checks in your test runner or Storybook.
- E2E checks for critical pages and flows.
- Keyboard behavior tests that axe cannot fully validate.
Tools and what they catch#
| Tool | Best at | Misses |
|---|---|---|
| eslint-plugin-jsx-a11y | Obvious JSX mistakes | Runtime focus and dynamic rendering |
| axe-core | Many WCAG rule violations | Complex usability, some focus order issues |
| Playwright | Real keyboard navigation and focus assertions | Semantic issues without explicit assertions |
| Manual screen reader spot-check | Real experience | Not scalable for every PR |
Playwright plus axe example#
This pattern runs axe on a page and fails the test if violations exist.
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("settings page has no axe violations", async ({ page }) => {
await page.goto("/settings");
const results = await new AxeBuilder({ page })
.exclude('[data-test="third-party-widget"]')
.analyze();
expect(results.violations).toEqual([]);
});Keep exclusions rare and justified. If you must exclude, attach a ticket and a date to remove it in the code review process.
Playwright keyboard navigation test example#
Test a modal flow end-to-end: open, focus is inside, Escape closes, focus returns.
import { test, expect } from "@playwright/test";
test("modal traps focus and restores focus on close", async ({ page }) => {
await page.goto("/account");
const openButton = page.getByRole("button", { name: "Open dialog" });
await openButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: "Account details" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Account details" })).toBeHidden();
await expect(openButton).toBeFocused();
});This is the kind of test that prevents regressions when someone refactors modal internals.
# 10) Integrate Accessibility Checks Into CI#
CI is where your React accessibility checklist becomes enforceable. The goal is to stop regressions before they merge, not to generate reports nobody reads.
Recommended CI stages#
| Stage | Runs when | What to run | Why |
|---|---|---|---|
| Lint | Every PR | ESLint with a11y rules | Fast feedback |
| Unit | Every PR | Component tests plus optional axe | Cheap coverage |
| E2E smoke | Every PR | Playwright key flows plus axe on key pages | Catch real app regressions |
| Full E2E | Nightly or pre-release | More routes, more devices | Broader safety net |
Example GitHub Actions job for Playwright and axe#
Keep it minimal and deterministic.
# CI steps overview
npm ci
npm run build
npm run start:test &
# wait for server, then run playwright
npx playwright install --with-deps
npm run test:e2eIn your test:e2e script, run Playwright with a smaller project matrix on PRs and a larger one on nightly builds.
⚠️ Warning: If your E2E tests are flaky, teams will disable them. Stabilize selectors, wait conditions, and test data before adding strict a11y gates.
# Common Pitfalls and How to Avoid Them#
- 1Removing focus outlines globally — Use
:focus-visibleand keep a strong indicator. - 2Using ARIA as decoration — ARIA must reflect behavior. If you add
role="menu", you must implement menu keyboard rules. - 3Not restoring focus after modal close — Store the opener element and restore it, especially in multi-step flows.
- 4Placeholder as label — Always use a real label. Placeholder is a hint, not a name.
- 5Auto-dismissing critical errors — Error toasts should not disappear before the user can act.
# Key Takeaways#
- Prefer semantic HTML and add ARIA only when you implement the full expected behavior for that widget.
- Ensure full keyboard support: Tab order, visible focus, Escape to close, and arrow key navigation where patterns require it.
- Implement predictable focus management: move focus into modals, trap it, then restore it to the trigger on close.
- Make forms robust: labels,
aria-describedbyfor errors,aria-invalidfor invalid fields, and an error summary for long forms. - Add automated gates: axe checks for key routes and Playwright keyboard tests for critical flows, enforced in CI.
# Conclusion#
A React accessibility checklist only works if it is repeatable: semantic-first components, consistent keyboard behavior, disciplined focus management, and CI tests that prevent regressions. If you want help auditing an existing React or Next.js app, or you need a component library that bakes in a11y by default, Samioda can implement the patterns, tests, and CI gates as part of your delivery pipeline. Reach out and we will review your key flows, define a practical checklist for your product, and ship improvements without slowing down releases.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →React Table Virtualization & Infinite Scroll: Building Fast Data Grids with TanStack (2026 Guide)
Learn React table virtualization with TanStack Table, TanStack Virtual, and React Query: efficient rendering, infinite scroll, server-side sorting/filtering, URL sync, selection persistence, and optimistic updates.
Next.js Real-Time Features: WebSockets vs SSE vs Supabase Realtime (When to Use What)
A practical comparison of Next.js real-time options—WebSockets, Server-Sent Events, and Supabase Realtime—covering hosting constraints, scalability, auth, cost, and which to use for chat, dashboards, notifications, and collaborative editing.
Next.js Rate Limiting & Bot Protection: Patterns for APIs, Server Actions, and Edge (2026 Guide)
Practical Next.js rate limiting patterns for Route Handlers, Server Actions, and Edge runtime — with token bucket strategies, Redis-backed limits, WAF/CDN rules, monitoring, and false-positive mitigation.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
React Performance in 2026: Profiling, Memoization, and Rendering Patterns That Actually Work
A practical step-by-step guide to React performance profiling and memoization in 2026: how to diagnose slow UIs with React DevTools Profiler and why-did-you-render, pick the right rendering patterns, and avoid premature optimization.
React Forms at Scale: React Hook Form + Zod Patterns for Complex Products
React forms best practices for large apps using React Hook Form and Zod: schema-first validation, reusable fields, async checks, multi-step flows, performance, accessibility, and server/API integration patterns.
React Table Virtualization & Infinite Scroll: Building Fast Data Grids with TanStack (2026 Guide)
Learn React table virtualization with TanStack Table, TanStack Virtual, and React Query: efficient rendering, infinite scroll, server-side sorting/filtering, URL sync, selection persistence, and optimistic updates.