# Accessible React Components: Dialogs, Menus, Focus

> Accessible React components start with the right native element, then add focus management and only the ARIA the pattern needs. A modal should use the native dialog element with showModal, a menu button needs arrow-key navigation and focus return, and every interactive part must work with a keyboard and meet WCAG 2.2 AA.

- Author: Sami Ullah (https://samiullah.tech/about)
- Published: 2026-11-11
- Updated: 2026-11-13
- Tags: react, accessibility, typescript
- Canonical: https://samiullah.tech/blog/accessible-react-components

Accessible React components are built from three layers: the right native HTML element, correct focus management, and only as much ARIA as the pattern needs. This post walks through a modal dialog and a menu button in React 19 with TypeScript, then lists the WCAG 2.2 criteria that most often catch component code. Checked against WCAG 2.2 and the ARIA Authoring Practices Guide in Nov 2026.

## What makes a React component accessible?

A component is accessible when people can perceive it, operate it with any input method, and understand what it does, whether they use a mouse, a keyboard, a switch device or a screen reader. In code terms, that means three things.

1. **Semantics.** The browser exposes each element's role, name and state to assistive technology through the accessibility tree. A `button` element is announced as a button with its text as the name. A `div` with an `onClick` is announced as nothing at all.
2. **Keyboard operation.** Everything that works with a pointer must work with Tab, Shift+Tab, Enter, Space, Escape and, for composite widgets, the arrow keys.
3. **Focus management.** When content appears or disappears, focus must move somewhere sensible and come back when it closes.

ARIA (Accessible Rich Internet Applications) is a set of attributes that change what the accessibility tree reports. It changes announcements only; it does not add keyboard behaviour. That is why the first rule of ARIA is to use a native element if one exists.

## Why start with native HTML elements?

Native elements give you role, keyboard support and focus behaviour for free, and browsers keep improving them. A `button` is focusable, fires on Enter and Space, and is announced correctly. Rebuilding that on a `div` takes `role`, `tabIndex`, two key handlers and still misses details like form submission.

This table is the one I check before writing any custom widget:

| You need | Use this | Not this |
| --- | --- | --- |
| Something that does an action | `button` | `div` or `a` without `href` with a click handler |
| Something that goes to a URL | `a` with `href` | `button` that calls `router.push` |
| A modal | `dialog` with `showModal()` | A positioned `div` with `role="dialog"` |
| Show and hide a section | `button` with `aria-expanded`, or `details` | A clickable heading |
| A checkbox or switch | `input type="checkbox"` | A styled `span` |
| A choice from a list in a form | `select` | A custom listbox, unless you truly need rich options |

Custom widgets are sometimes necessary, but each one is a commitment to implement a full keyboard pattern from the [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/patterns/).

## How do I build an accessible modal dialog in React?

Use the native `dialog` element and open it with `showModal()`. A modal dialog opened this way sits in the browser's top layer, makes everything outside it inert so Tab cannot leave it, closes on Escape, and, in current browsers, returns focus to the element that opened it when it closes.

```tsx
"use client";

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

export function Modal({ open, onClose, title, children }: ModalProps) {
  const ref = useRef<HTMLDialogElement>(null);
  const titleId = useId();

  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
  }, [open]);

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      onClose={onClose}
      onClick={(e) => {
        // A click on the backdrop targets the dialog element itself.
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <h2 id={titleId}>{title}</h2>
      {children}
      <button type="button" onClick={onClose}>
        Close
      </button>
    </dialog>
  );
}
```

A few decisions in that component are deliberate:

- **`onClose` covers Escape.** Pressing Escape fires `cancel` and then `close` on the dialog, so the parent's `open` state stays in sync without a key handler.
- **`aria-labelledby` points at the heading.** Screen readers announce "Delete project, dialog" instead of just "dialog".
- **The backdrop click check.** Clicks on the `::backdrop` are reported with the dialog itself as the target. If the dialog has padding, clicks on that padding also hit the dialog, so give the content an inner wrapper with the padding instead.
- **Initial focus.** By default the browser focuses the first focusable element inside. For a destructive confirmation, put `autoFocus` on the safe "Cancel" button so Enter does not delete anything by accident.

If you need the scroll behind the modal to stop, add `overflow: hidden` to `body` while it is open; the native dialog does not do that for you.

Two modal bugs are worth testing for explicitly. First, focus that never comes back: if the trigger button is unmounted while the dialog is open, for example because the row it lived in was re-rendered with a new `key`, the browser has nowhere to return focus to and it lands on `body`. Keep the trigger mounted, or store a fallback element and focus it in `onClose`. Second, a dialog rendered as an ordinary positioned `div` inside a container with `overflow: hidden` or a `transform` can be clipped or sit under a sticky header; `showModal()` avoids both because the top layer ignores the parent's stacking context.

## How do I build a menu button that works with a keyboard?

A menu button is a button that opens a list of actions, and the ARIA pattern expects arrow keys to move between items, Escape to close, and focus to return to the button. Use it for application commands like "Rename" and "Delete", not for site navigation, which should be a disclosure with ordinary links.

```tsx
"use client";

type Item = { label: string; onSelect: () => void };

export function MenuButton({ label, items }: { label: string; items: Item[] }) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const menuId = useId();

  useEffect(() => {
    if (open) itemRefs.current[active]?.focus();
  }, [open, active]);

  function close() {
    setOpen(false);
    buttonRef.current?.focus();
  }

  function onMenuKeyDown(e: KeyboardEvent<HTMLUListElement>) {
    const last = items.length - 1;
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setActive((i) => (i === last ? 0 : i + 1));
        break;
      case "ArrowUp":
        e.preventDefault();
        setActive((i) => (i === 0 ? last : i - 1));
        break;
      case "Home":
        e.preventDefault();
        setActive(0);
        break;
      case "End":
        e.preventDefault();
        setActive(last);
        break;
      case "Escape":
        e.preventDefault();
        close();
        break;
      case "Tab":
        setOpen(false);
        break;
    }
  }

  return (
    <div>
      <button
        ref={buttonRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={menuId}
        onClick={() => {
          setActive(0);
          setOpen((o) => !o);
        }}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown" || e.key === "ArrowUp") {
            e.preventDefault();
            setActive(e.key === "ArrowDown" ? 0 : items.length - 1);
            setOpen(true);
          }
        }}
      >
        {label}
      </button>
      {open && (
        <ul id={menuId} role="menu" aria-label={label} onKeyDown={onMenuKeyDown}>
          {items.map((item, i) => (
            <li key={item.label} role="none">
              <button
                ref={(el) => {
                  itemRefs.current[i] = el;
                }}
                type="button"
                role="menuitem"
                tabIndex={i === active ? 0 : -1}
                onClick={() => {
                  item.onSelect();
                  close();
                }}
              >
                {item.label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

The technique for moving focus is called a roving tabindex: only the active item has `tabIndex` 0, the rest have -1, and arrow keys move both the index and the real DOM focus. This keeps the whole menu as one Tab stop, which is what the pattern expects. The `role="none"` on each `li` removes list semantics so the screen reader announces menu items, not "list item".

What this example leaves out: closing on an outside click, type-ahead (pressing "D" jumps to "Delete"), and positioning near the viewport edge. Those are the reasons I usually reach for a headless library such as React Aria or Radix in production, and why the [accessible component library concept project](/work/accessible-component-library) documents each pattern with its keyboard map in Storybook. You can tab through the dialog and menu yourself in my [accessible component library demo](/lab/accessible-component-library), which runs automated axe tests on every component.

## How should focus move when content changes?

Focus should follow the user's attention: into new content they opened, back to the trigger when it closes, and to a meaningful place after something they focused is removed. Lost focus, where the focused element disappears and focus falls back to `body`, is one of the most common accessibility bugs in React apps, because conditional rendering removes elements without warning.

Common cases and what to do:

1. **A row is deleted.** Move focus to the next row, or to the list heading if it was the last one.
2. **A form submits with errors.** Move focus to an error summary at the top, or to the first invalid field. The [form handling with zod](/blog/react-form-handling-zod) post shows the `aria-invalid` and `aria-describedby` wiring for the fields.
3. **A route changes in a single-page app.** Move focus to the new page's `h1` (give it `tabIndex={-1}`) so screen reader users hear where they are.
4. **Content loads after a search.** Do not move focus; announce the result count with a polite live region instead.

A live region is an element whose text changes are read out by screen readers without moving focus:

```tsx
export function ResultCount({ count }: { count: number }) {
  return (
    <p role="status" aria-live="polite">
      {count === 1 ? "1 result" : `${count} results`}
    </p>
  );
}
```

The element must exist in the DOM before its text changes. Rendering a live region at the same moment as its message often results in nothing being read.

## Which WCAG 2.2 criteria matter most for components?

WCAG 2.2, published by the W3C in October 2023, added nine success criteria, and five of them affect component work directly at level AA or A. The [WCAG 2.2 "What's New" page](https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/) explains each with examples.

| Criterion | Level | What it means for a React component |
| --- | --- | --- |
| 2.4.11 Focus Not Obscured (Minimum) | AA | A focused element must not be fully hidden by a sticky header, cookie banner or chat widget. Use `scroll-padding-top`. |
| 2.5.7 Dragging Movements | AA | Anything done by dragging, such as reordering cards, needs a single-pointer alternative like "Move up" buttons. |
| 2.5.8 Target Size (Minimum) | AA | Pointer targets at least 24 by 24 CSS pixels, or spaced so a 24 px circle around each does not overlap another. |
| 3.3.8 Accessible Authentication (Minimum) | AA | Login cannot require a memory or puzzle test without an alternative. Allow paste and password managers. |
| 3.3.7 Redundant Entry | A | Do not ask for the same information twice in one process; prefill or offer "same as billing". |

Dragging Movements matters for any drag-and-drop UI. A kanban board built with dnd-kit gets keyboard dragging through its keyboard sensor, but a pointer-only user who cannot drag still needs buttons or a menu to move a card.

Automated tools such as axe and Lighthouse catch missing labels, contrast failures and invalid ARIA, but they cannot tell you that focus jumps to the top of the page after a delete, or that a menu announces the wrong item count. Those only show up when you test with a keyboard and a screen reader, so treat a clean axe report as the starting point, not the finish line.

## What should you do first?

Unplug your mouse for fifteen minutes and use the most important flow in your app, such as signup or checkout, with only the keyboard. Write down every place where focus disappears, becomes invisible, or gets trapped, and every control you cannot reach. Fix those before adding any ARIA, starting by swapping clickable `div` elements for real buttons and replacing a custom modal with the native dialog. Then run axe on the same pages to catch labels and contrast. The [React skill page](/skills/react) lists the projects where I apply these patterns.

## FAQ

### Should I use the native dialog element or a library for modals in React?

The native dialog element with showModal is a good default today. All major browsers support it, it makes the rest of the page inert, closes on Escape and places the dialog in the top layer above everything else. A library like Radix or React Aria still helps when you need nested popovers, complex positioning or consistent behaviour across older browsers your users run.

### What is the difference between a menu and a disclosure in ARIA?

A menu, with role menu and menuitem, is meant for application commands such as Rename, Duplicate and Delete, and it expects arrow-key navigation. A disclosure is a button that shows and hides a section, with aria-expanded on the button. Site navigation links almost always belong in a disclosure, because screen reader users expect normal link behaviour there, not an application menu.

### What changed in WCAG 2.2 for React developers?

WCAG 2.2 added several success criteria that affect component work. Focus Not Obscured requires a focused element to stay at least partly visible, so sticky headers must not cover it. Target Size Minimum asks for 24 by 24 CSS pixel targets or enough spacing. Dragging Movements requires a non-drag alternative. Accessible Authentication limits memory puzzles on login forms.

### How do I test React components for accessibility?

Combine three checks. Run an automated scanner such as axe in your tests or browser to catch missing labels and contrast problems. Use the component with only a keyboard, checking that focus is visible and moves in a logical order. Then try it with a screen reader, such as NVDA on Windows or VoiceOver on macOS, because automated tools find only a part of real problems.
