Web Engineering ·

Building Accessible Web Applications: ARIA Patterns, Keyboard Navigation, and Automated Testing in React

A practical guide to building accessible React applications with semantic HTML, ARIA patterns, keyboard navigation, accessible custom components, and automated testing using axe-core and Playwright.

Building Accessible Web Applications: ARIA Patterns, Keyboard Navigation, and Automated Testing in React

The European Accessibility Act (EAA) took effect in June 2025, requiring that digital products and services sold in the EU meet WCAG 2.1 AA standards. ADA web accessibility lawsuits in the United States have been climbing for years, with over 4,000 federal cases filed in 2023 alone. Beyond the legal pressure: an estimated 1.3 billion people worldwide live with some form of disability. Accessible applications reach more users, score better in search engines, and tend to have cleaner component architecture.

This guide covers the technical implementation side: semantic HTML as the foundation, ARIA roles for custom components, keyboard navigation patterns, accessible modals and dropdowns, and automated testing pipelines you can wire into CI.

Semantic HTML First

Before reaching for ARIA, exhaust what HTML gives you natively. A <button> is keyboard focusable, activatable with Enter and Space, and announced correctly by screen readers without any additional attributes. A <div onClick={...}> is none of those things.

The pattern to internalize: if a native HTML element does what you need, use it. ARIA fills the gaps for custom components that HTML does not have a native equivalent for.

// Wrong: loses all native semantics
<div onClick={handleSubmit} className="btn">Submit</div>

// Right: native button handles focus, keyboard activation, and role announcement
<button type="button" onClick={handleSubmit}>Submit</button>

// Right for navigation: nav landmark + list structure
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/dashboard">Dashboard</a></li>
    <li><a href="/settings">Settings</a></li>
  </ul>
</nav>

Landmarks (<main>, <nav>, <aside>, <header>, <footer>) let screen reader users jump between sections without tabbing through every element. Most React apps skip these entirely and render a flat div soup. Adding them costs nothing and significantly improves navigation for assistive technology users.

ARIA Roles, States, and Properties

ARIA (Accessible Rich Internet Applications) is a set of attributes that communicate component semantics, state, and relationships to the accessibility tree. The three categories matter for different reasons:

  • Roles (role="dialog", role="listbox") define what a component is
  • States (aria-expanded, aria-selected, aria-checked) describe current condition
  • Properties (aria-label, aria-describedby, aria-controls) describe relationships and labels

A common mistake is adding role without the corresponding states. A role="button" element that does not respond to keyboard events or update aria-pressed is worse than no ARIA at all because it creates a false promise to assistive technology.

Accessible Modal Dialog

The modal pattern requires several coordinated behaviors: focus trap, aria-modal, labeling, and correct focus return.

import { useEffect, useRef, ReactNode } from "react";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Store the element that triggered the modal so we can return focus
      triggerRef.current = document.activeElement as HTMLElement;

      // Move focus into the dialog on open
      const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    } else {
      // Return focus to the trigger element on close
      triggerRef.current?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        onClose();
        return;
      }

      if (event.key !== "Tab") return;

      const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      if (!focusable || focusable.length === 0) return;

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      role="presentation"
      className="modal-overlay"
      onClick={(e) => e.target === e.currentTarget && onClose()}
    >
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button type="button" onClick={onClose} aria-label="Close dialog">
          Close
        </button>
      </div>
    </div>
  );
}

Key decisions here: aria-modal="true" tells screen readers to treat the dialog as a separate context and ignore content behind it. The focus trap in the keydown handler prevents keyboard users from tabbing outside the dialog. Returning focus to triggerRef.current on close preserves the user’s location in the document.

Accessible Dropdown / Listbox

A custom dropdown requires the listbox pattern. The roving tabindex technique keeps only one item in the tab sequence at a time.

import { useState, useRef, KeyboardEvent } from "react";

interface Option {
  value: string;
  label: string;
}

interface ListboxProps {
  options: Option[];
  value: string;
  onChange: (value: string) => void;
  label: string;
}

export function Listbox({ options, value, onChange, label }: ListboxProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  const selectedOption = options.find((o) => o.value === value);
  const listboxId = "listbox-options";

  function openAndFocusFirst() {
    setIsOpen(true);
    setActiveIndex(options.findIndex((o) => o.value === value) || 0);
    // Focus the list after state update
    requestAnimationFrame(() => {
      listRef.current?.focus();
    });
  }

  function handleButtonKeyDown(e: KeyboardEvent) {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      openAndFocusFirst();
    }
  }

  function handleListKeyDown(e: KeyboardEvent) {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setActiveIndex((i) => Math.min(i + 1, options.length - 1));
        break;
      case "ArrowUp":
        e.preventDefault();
        setActiveIndex((i) => Math.max(i - 1, 0));
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        onChange(options[activeIndex].value);
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
      case "Escape":
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
      case "Home":
        e.preventDefault();
        setActiveIndex(0);
        break;
      case "End":
        e.preventDefault();
        setActiveIndex(options.length - 1);
        break;
    }
  }

  return (
    <div className="listbox-container">
      <label id="listbox-label">{label}</label>
      <button
        ref={buttonRef}
        type="button"
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-labelledby="listbox-label"
        aria-controls={listboxId}
        onClick={() => (isOpen ? setIsOpen(false) : openAndFocusFirst())}
        onKeyDown={handleButtonKeyDown}
      >
        {selectedOption?.label ?? "Select an option"}
      </button>

      {isOpen && (
        <ul
          ref={listRef}
          id={listboxId}
          role="listbox"
          aria-labelledby="listbox-label"
          tabIndex={-1}
          onKeyDown={handleListKeyDown}
          onBlur={() => setIsOpen(false)}
        >
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={option.value === value}
              id={`option-${option.value}`}
              tabIndex={index === activeIndex ? 0 : -1}
              onClick={() => {
                onChange(option.value);
                setIsOpen(false);
                buttonRef.current?.focus();
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

The roving tabindex pattern: only the active option has tabIndex={0}, all others have tabIndex={-1}. Arrow keys move the active index. The list element itself has tabIndex={-1} so it can receive programmatic focus but does not appear in the natural tab order.

Accessible Data Table

Tables need explicit header associations for screen readers to announce context with cell values.

interface Column<T> {
  key: keyof T;
  header: string;
  sortable?: boolean;
}

interface SortState {
  key: string;
  direction: "ascending" | "descending";
}

interface DataTableProps<T extends { id: string }> {
  columns: Column<T>[];
  rows: T[];
  caption: string;
  onSort?: (key: keyof T, direction: "ascending" | "descending") => void;
  sortState?: SortState;
}

export function DataTable<T extends { id: string }>({
  columns,
  rows,
  caption,
  onSort,
  sortState,
}: DataTableProps<T>) {
  function handleSortClick(col: Column<T>) {
    if (!onSort || !col.sortable) return;
    const direction =
      sortState?.key === String(col.key) && sortState.direction === "ascending"
        ? "descending"
        : "ascending";
    onSort(col.key, direction);
  }

  return (
    <table>
      <caption>{caption}</caption>
      <thead>
        <tr>
          {columns.map((col) => (
            <th
              key={String(col.key)}
              scope="col"
              aria-sort={
                sortState?.key === String(col.key)
                  ? sortState.direction
                  : col.sortable
                  ? "none"
                  : undefined
              }
            >
              {col.sortable && onSort ? (
                <button
                  type="button"
                  onClick={() => handleSortClick(col)}
                >
                  {col.header}
                  <span aria-hidden="true">
                    {sortState?.key === String(col.key)
                      ? sortState.direction === "ascending"
                        ? " ↑"
                        : " ↓"
                      : " ↕"}
                  </span>
                </button>
              ) : (
                col.header
              )}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row) => (
          <tr key={row.id}>
            {columns.map((col) => (
              <td key={String(col.key)}>{String(row[col.key])}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

scope="col" on header cells creates the association screen readers use to announce “Name: Alice” instead of just “Alice” when navigating cells. aria-sort communicates sort state without relying on visual indicators alone. The sort icons carry aria-hidden="true" because the aria-sort attribute conveys the same information.

Accessible Forms

Form fields need three things: a programmatically associated label, error messaging that is announced without requiring visual context, and a logical focus order.

interface FieldProps {
  id: string;
  label: string;
  error?: string;
  hint?: string;
  required?: boolean;
}

export function TextField({
  id,
  label,
  error,
  hint,
  required,
  ...inputProps
}: FieldProps & React.InputHTMLAttributes<HTMLInputElement>) {
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  // Build aria-describedby from whichever elements exist
  const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className="field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>

      {hint && (
        <p id={hintId} className="field-hint">
          {hint}
        </p>
      )}

      <input
        id={id}
        aria-describedby={describedBy}
        aria-invalid={error ? "true" : undefined}
        aria-required={required}
        {...inputProps}
      />

      {error && (
        <p id={errorId} role="alert" className="field-error">
          {error}
        </p>
      )}
    </div>
  );
}

role="alert" causes screen readers to announce the error text immediately when it appears, without requiring the user to navigate to it. aria-invalid="true" signals that the field contains an invalid value. aria-describedby chains hint and error IDs so both are read when the field receives focus.

The asterisk for required fields is marked aria-hidden="true" because the visual convention carries no semantic meaning to screen readers. The explicit “(required)” text in a .sr-only span (visually hidden, accessible to screen readers) carries the intent.

Color Contrast and Visual Indicators

WCAG 2.1 AA requires 4.5:1 contrast for normal text and 3:1 for large text (18pt regular or 14pt bold). It also requires that non-text indicators like focus rings, form borders, and state changes meet 3:1 against adjacent colors.

The common failure mode in React apps: removing the browser’s default focus ring without replacing it.

/* Wrong: destroys keyboard navigation visibility */
:focus {
  outline: none;
}

/* Right: replace with a visible custom ring that works on any background */
:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
  border-radius: 2px;
}

:focus-visible is the correct selector here. It applies the focus ring only when the user is navigating by keyboard (or similar), not when clicking with a mouse. This satisfies both the “visible focus indicator” requirement and the designer complaint that focus rings look wrong on click interactions.

Headless UI Library Comparison

Building every accessible component from scratch is viable for small component sets. For larger projects, headless UI libraries provide the behavioral layer (keyboard handling, ARIA, focus management) while leaving styling entirely to you.

LibraryApproachKeyboard patternsARIA coverageBundle sizeTypeScriptBest for
Radix UIUnstyled component primitivesComprehensive (follows WAI-ARIA authoring practices)ExcellentPer-package imports, smallFirst-classProjects needing unstyled primitives with full ARIA
React Aria (Adobe)Hooks + componentsComprehensive, internationalization-awareExcellentTree-shakeableFirst-classComplex apps, i18n requirements, Adobe DS
Headless UI (Tailwind)Components only, no hooksGood, covers common patternsGoodSmallGoodTailwind CSS projects, simpler component sets
AriakitHooks + componentsComprehensiveExcellentTree-shakeableFirst-classComposable primitives, custom design systems

The key tradeoff is between composability and completeness. React Aria provides hooks you can attach to your own DOM elements, giving you full control over markup structure. Radix provides opinionated component primitives that enforce correct markup. Headless UI gives you fewer escape hatches but covers 80% of use cases with less complexity.

The case against rolling your own: the keyboard patterns for comboboxes, date pickers, and tree views are genuinely complex to get right across browsers and screen readers. Radix and React Aria have invested significant effort in cross-browser and cross-reader testing. That work is not worth duplicating unless your component requirements are unusual.

Automated Testing

Manual screen reader testing is not scalable as a sole quality gate. Automated tools catch a different class of issues: missing labels, invalid ARIA usage, contrast failures, and structural problems.

axe-core in Jest

npm install --save-dev @axe-core/react jest-axe
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Modal } from "./Modal";

expect.extend(toHaveNoViolations);

describe("Modal accessibility", () => {
  it("has no axe violations when open", async () => {
    const { container } = render(
      <Modal isOpen={true} onClose={() => {}} title="Confirm action">
        <p>Are you sure you want to delete this item?</p>
        <button type="button">Confirm</button>
      </Modal>
    );

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

axe-core catches around 30-40% of WCAG issues automatically. It will not catch whether your focus trap actually works or whether the focus return after close is correct. Those require interaction testing.

Playwright Accessibility Snapshots

Playwright’s accessibility tree snapshots let you assert on what screen readers actually see.

import { test, expect } from "@playwright/test";

test("modal dialog is announced correctly", async ({ page }) => {
  await page.goto("/components/modal");

  await page.getByRole("button", { name: "Open modal" }).click();

  // Assert the dialog role is present with the correct name
  const dialog = page.getByRole("dialog", { name: "Confirm action" });
  await expect(dialog).toBeVisible();

  // Snapshot the accessibility tree of the dialog
  const snapshot = await dialog.ariaSnapshot();
  expect(snapshot).toMatchSnapshot("modal-dialog.aria.yml");
});

test("listbox keyboard navigation works", async ({ page }) => {
  await page.goto("/components/listbox");

  const button = page.getByRole("combobox", { name: "Status" });
  await button.focus();
  await page.keyboard.press("ArrowDown");

  const listbox = page.getByRole("listbox");
  await expect(listbox).toBeVisible();

  await page.keyboard.press("ArrowDown");
  await page.keyboard.press("Enter");

  // Verify selection was applied
  await expect(button).toHaveText("Active");
  // Verify focus returned to trigger
  await expect(button).toBeFocused();
});

test("form error is announced on invalid submit", async ({ page }) => {
  await page.goto("/components/form");

  await page.getByRole("button", { name: "Submit" }).click();

  // The error should appear with role="alert"
  const error = page.getByRole("alert");
  await expect(error).toBeVisible();
  await expect(error).toHaveText(/required/i);
});

The aria snapshot format serializes the accessibility tree to a YAML file on first run. Subsequent runs diff against the snapshot, catching regressions in ARIA attributes or DOM structure that would affect screen reader output.

CI Integration

# .github/workflows/accessibility.yml
name: Accessibility

on: [push, pull_request]

jobs:
  axe:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test -- --testPathPattern="accessibility|a11y"

  playwright:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --project=chromium tests/accessibility/
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Keep accessibility tests in a dedicated directory (tests/accessibility/) so they run as a focused suite in CI without slowing down unit test runs. Use actions/upload-artifact to preserve the Playwright report on failure, which includes accessibility tree snapshots and screenshots for debugging.

Production Considerations

A few things that bite teams after the component work is done:

Dynamic content updates. SPAs that update content without a page reload need aria-live regions for announcements. A route change that swaps the main content area without announcing the new page title will leave screen reader users disoriented. A common pattern is an off-screen live region that announces the page title after navigation.

function RouteAnnouncer() {
  const [message, setMessage] = useState("");
  const pathname = usePathname(); // Next.js App Router

  useEffect(() => {
    // Small delay ensures the DOM has updated with the new page title
    const timer = setTimeout(() => {
      setMessage(document.title);
    }, 100);
    return () => clearTimeout(timer);
  }, [pathname]);

  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

Reduced motion. Users with vestibular disorders can be affected by animations. The prefers-reduced-motion media query should disable or reduce animations in CSS. In React, you can read this preference via a hook.

function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener("change", handler);
    return () => mq.removeEventListener("change", handler);
  }, []);

  return reduced;
}

Screen reader testing. Automated tools will not catch everything. Budget time to test with real screen readers: NVDA + Chrome on Windows, VoiceOver + Safari on macOS/iOS, TalkBack on Android. The interaction model differs between screen readers in ways that ARIA alone does not fully normalize. Focus on your highest-risk components: modals, dropdowns, data tables, and multi-step forms.

Contrast in dark mode. If your app supports dark mode, verify contrast ratios in both modes. A text color that passes 4.5:1 on a white background may fail on a dark background, and vice versa. Design tokens that encode contrast-safe pairs are more reliable than per-component color decisions.

The Real Cost of Skipping This

Accessibility is not a checkbox you tick before launch. A screen reader user who cannot navigate your modal, cannot submit your form, or cannot read your data table is locked out entirely. That is a failed product for that user.

The EAA and ADA litigation context makes the legal case, but the engineering case is simpler: accessible components are better components. Keyboard navigability means your components work without a mouse. Clear label associations mean your components are testable by attribute selectors. Correct ARIA state means your UI is machine-readable in ways that enable automation, testing, and integration.

The floor is semantic HTML. Everything above it (ARIA, keyboard traps, live regions) fills gaps for component types that HTML does not cover natively. Start with the floor, reach for headless libraries before rolling custom behavior, and wire automated testing into CI so regressions do not ship silently.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.