Web Development
ReactAccessibilityA11yARIATestingPlaywrightaxeFrontend

React Accessibility Checklist: ARIA, Keyboard Navigation, Focus Management, and Testing (2026)

AO
Adrijan Omićević
·18 min read

# 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#

RequirementRecommendedNotes
React18+Examples assume function components and hooks
TestingPlaywrightFor keyboard and focus E2E checks
A11y engineaxe-coreFor automated rule checks
Lintingeslint-plugin-jsx-a11yFast feedback in local dev
Basic forms knowledgeReact Hook Form and ZodUseful 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.

AreaCheckWhy it matters
SemanticsUse native elements firstBuilt-in keyboard and screen reader support
ARIAAdd only when needed, and match expected behaviorIncorrect ARIA often makes UI worse
KeyboardAll interactive UI is usable without a mouseRequired for many users and power users
FocusVisible focus, correct focus order, no trapsPrevents users from getting stuck
FormsProper labels, errors, and validation messagesPrevents abandonment and confusion
ModalsFocus trap, Escape close, restore focusAvoids keyboard traps and context loss
MenusCorrect roles and arrow key behaviorConsistent navigation pattern
ToastsAnnounce changes without hijacking focusUsers need feedback without interruption
Testingaxe plus Playwright plus small manual checksAutomation plus behavior coverage
CIRun checks on PR and block regressionsKeeps 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#

IntentPreferAvoidReason
Button actionbuttondiv with onClickbutton supports Enter and Space and has role
Navigationnav with linksClickable list itemsScreen readers understand regions and links
Form inputslabel plus inputPlaceholder-only labelPlaceholder is not a label and disappears
Headingsh1 to h6Styled textHeading structure powers navigation

⚠️ Warning: Do not add role="button" to a div and call it done. You still need keyboard handlers, focusability, and correct states. In most cases, replacing it with a real button is 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:

  1. 1
    Visible label text associated with the element.
  2. 2
    aria-label for icon-only controls.
  3. 3
    aria-labelledby pointing 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#

CheckHow to validate quicklyCommon failure
All interactive elements reachable with TabTab through the pageClickable elements not focusable
Focus order matches visual orderTab and observe highlightCSS reordering causes confusing focus
Visible focus indicatorTab and look for clear outlineFocus ring removed with CSS
No keyboard trapTab out of componentsFocus stuck inside a widget
Escape closes transient UIOpen modal or menu, press EscapeOnly click closes
Arrow keys behave in menusUse arrow keys in menuTab cycles through menu items instead of arrows

💡 Tip: Keep the browser focus ring. If design requires custom focus styling, use :focus-visible and do not remove outline without a replacement.

A minimal global CSS baseline that preserves usability:

CSS
/* 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#

ScenarioExpected focus behaviorImplementation hint
Route changeFocus goes to page heading or main landmarkFocus main or h1 programmatically
Modal openFocus moves into modal, usually first focusableFocus trap library or custom trap
Modal closeFocus returns to the element that opened itStore document.activeElement before open
Form submit errorFocus moves to error summary or first invalid fieldFocus the first error input
Toast appearsDo not move focusUse live regions instead

A practical focus restore pattern#

Store the last focused element, then restore it on close.

JavaScript
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#

ItemMust-have behaviorExample approach
LabelsEach input has a programmatic labellabel with htmlFor and input id
Required fieldsCommunicate required staterequired plus visible indicator text
ErrorsError message is associated with the fieldaria-describedby linking to error id
Invalid stateScreen readers know field is invalidaria-invalid="true" when error exists
Error summaryOptional but recommended for long formsList errors at top and move focus
AutocompleteUse correct autocomplete tokensautoComplete="email" and similar

Concrete example: accessible input with error message#

JavaScript
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:

  1. 1
    Render summary only when errors exist.
  2. 2
    Give it a heading and a list of links to fields.
  3. 3
    Move focus to the summary on submit if there are errors.
JavaScript
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.

ItemRequirementNotes
RoleUse dialog semanticsPrefer native dialog when feasible
LabelProvide name and optionally descriptionaria-labelledby, aria-describedby
FocusMove focus into modal on openUsually first focusable element
TrapPrevent focus from leaving modalUse proven library if possible
CloseEscape closes and close button is focusableDo not rely on backdrop only
RestoreReturn focus to triggerUse focus restore hook
BackgroundHide background from assistive techUse 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.

JavaScript
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#

UIUse semanticsWhen
Navigation linksnav plus aSite header, sidebar
Action menubutton plus menu widgetUser avatar menu, overflow menu
Select-like choice listselect or combobox patternForms and filters

Action menu checklist#

ItemRequirementExample
Triggerbutton with aria-haspopup="menu"Indicates a popup menu
Statearia-expanded reflects open statetrue when open
Menu roleContainer uses role="menu"For action menu pattern
ItemsEach item uses role="menuitem"Or menuitemcheckbox where needed
KeyboardArrow keys move between itemsRoving tabindex
CloseEscape closes, click outside closesRestore focus to trigger

Roving tabindex example for menu items#

Keep Tab on the trigger, and use arrow keys inside the menu.

JavaScript
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#

ItemRequirementRecommended ARIA
Do not move focusUser stays in current contextNo focus calls on show
Announce messageScreen reader hears itLive region
Error urgencyErrors should be more prominentrole="alert" or assertive live region
Dismiss buttonIf toast persists, it must be dismissibleButton with label
TimingAvoid short auto-dismiss for critical messagesKeep 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".

JavaScript
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#

RuleDoDo not
Prefer semanticsUse native elements when possibleRecreate button or input with div
Roles match behaviorrole="menu" only if you implement menu keyboard patternsAdd roles without implementing interactions
Labels requiredProvide accessible name for icon buttonsShip unlabeled controls
States reflect realityKeep aria-expanded syncedHardcode state values
Avoid redundant ARIALet native semantics speakAdd role="button" to a real button

Practical review questions:

  1. 1
    Does every interactive element have an accessible name that matches the UI text?
  2. 2
    If ARIA role is used, does keyboard behavior match user expectation for that role?
  3. 3
    Are 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#

ToolBest atMisses
eslint-plugin-jsx-a11yObvious JSX mistakesRuntime focus and dynamic rendering
axe-coreMany WCAG rule violationsComplex usability, some focus order issues
PlaywrightReal keyboard navigation and focus assertionsSemantic issues without explicit assertions
Manual screen reader spot-checkReal experienceNot scalable for every PR

Playwright plus axe example#

This pattern runs axe on a page and fails the test if violations exist.

JavaScript
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.

JavaScript
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.

StageRuns whenWhat to runWhy
LintEvery PRESLint with a11y rulesFast feedback
UnitEvery PRComponent tests plus optional axeCheap coverage
E2E smokeEvery PRPlaywright key flows plus axe on key pagesCatch real app regressions
Full E2ENightly or pre-releaseMore routes, more devicesBroader safety net

Example GitHub Actions job for Playwright and axe#

Keep it minimal and deterministic.

Bash
# 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:e2e

In 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#

  1. 1
    Removing focus outlines globally — Use :focus-visible and keep a strong indicator.
  2. 2
    Using ARIA as decoration — ARIA must reflect behavior. If you add role="menu", you must implement menu keyboard rules.
  3. 3
    Not restoring focus after modal close — Store the opener element and restore it, especially in multi-step flows.
  4. 4
    Placeholder as label — Always use a real label. Placeholder is a hint, not a name.
  5. 5
    Auto-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-describedby for errors, aria-invalid for 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

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.