Skip to content
Aback UI

AccessibilityReact

Accessible modal dialogs in React: a checklist

Everything an accessible React dialog has to do: role and labelling, focus entry, focus trap, Escape, focus return, scroll lock, and how to test each one.

By the Aback UI teamPublished 8 min read

An accessible modal dialog in React needs a dialog role with an accessible name, focus moved inside when it opens, focus kept inside while it is open, Escape to close, focus returned to the trigger when it closes, and a background that assistive technology and the pointer cannot reach. Each of these can be tested in a few lines, and each one is easy to get subtly wrong when you build the dialog yourself.

The checklist at a glance

The behaviour below follows the WAI-ARIA Authoring Practices dialog pattern (opens in a new tab), which is the reference most component libraries implement.

RequirementWhat to check
Role and nameScreen readers announce "dialog" and its title
Focus entryFocus lands inside the dialog on open
Focus trapTab and Shift+Tab never reach the page behind
EscapeEscape closes the dialog
Focus returnFocus goes back to the element that opened it
Inert backgroundScreen reader virtual cursor cannot read the page behind
Scroll lockThe page behind does not scroll
Visible focusEvery control in the dialog shows a focus indicator

1. Role and accessible name

The container needs role="dialog" so assistive technology announces it as a dialog, and aria-modal="true" to say that content outside it is unavailable. Give it a name with aria-labelledby pointing at the visible title, and optionally a description with aria-describedby:

tsx
<div role="dialog" aria-modal="true" aria-labelledby="edit-title" aria-describedby="edit-desc">
  <h2 id="edit-title">Edit profile</h2>
  <p id="edit-desc">Changes are visible to your team.</p>
  {/* fields and actions */}
</div>

A dialog without a name is announced as just "dialog". If the design has no visible title, keep one for screen readers with a visually hidden heading rather than dropping it.

2. Move focus into the dialog

When the dialog opens, focus must move inside it. Otherwise a keyboard user is left on the trigger, behind an overlay, and a screen reader user may not know anything opened. The APG guidance:

  • By default, focus the first focusable element.
  • If the content is long and focusing the first control would scroll the start out of view, focus a static element at the top, such as the title, with tabIndex={-1}.
  • For a destructive confirmation, consider focusing the least destructive action, so a stray Enter does not delete anything.

3. Keep focus inside

While a modal dialog is open, Tab from the last focusable element wraps to the first, and Shift+Tab from the first wraps to the last. Hand-rolled traps commonly break on details: a list of focusable selectors that misses [contenteditable] or a disabled button, a focusable element added after open, or a nested popover rendered in a portal outside the trap. A maintained primitive handles these cases; that is a strong reason not to write your own.

4. Close on Escape

Escape closes the dialog. Listen on the dialog, not the window, so that an open select or menu inside it can handle Escape first and close itself without closing the whole dialog. Most primitives expose a hook for this, such as an onEscapeKeyDown callback, for the rare case where closing must be prevented, for example while a save is in progress.

5. Return focus to the trigger

When the dialog closes, focus returns to the element that opened it, so keyboard users continue where they were. Two edge cases:

  • If the trigger no longer exists, for example because the dialog deleted the row it lived in, move focus to a logical place such as the next row or the list heading. Losing focus to <body> sends keyboard users back to the top of the page.
  • If the dialog completes a flow, moving focus to the result can make more sense than returning to the trigger.

6. Make the background inert

A focus trap only covers the Tab key. Screen reader users navigate with a virtual cursor that ignores focus, so the content behind the dialog must also be hidden from the accessibility tree. Three tools exist:

  • aria-modal="true" on the dialog, which tells assistive technology to ignore the rest of the page. Support has historically varied between screen readers.
  • aria-hidden="true" on every sibling of the dialog's container, which is what many libraries do.
  • The inert attribute (opens in a new tab) on the rest of the page, which removes it from the accessibility tree and also blocks focus and pointer interaction.

Pointer events outside the dialog should not activate anything either. An overlay covers the page visually; make sure it also receives the clicks.

7. Lock page scroll

Scrolling the page behind a modal is disorienting, especially on touch devices where a swipe inside a short dialog can scroll the page instead. Lock scrolling on the document while the dialog is open, and compensate for the scrollbar width so the layout does not shift when the scrollbar disappears. Keep the dialog itself scrollable when its content is taller than the viewport, so nothing becomes unreachable at high zoom.

8. Use an alert dialog for confirmations

A confirmation that interrupts the user and requires a response, such as "Delete this project?", is an alert dialog (opens in a new tab). It uses role="alertdialog" and the same focus rules. In practice it should not close on an outside click, since that would dismiss a decision without an answer. Label the actions with what they do, such as "Delete project" and "Cancel", rather than "OK".

How Aback UI handles this

The Dialog, Alert Dialog, and Sheet components are built on Radix UI primitives. For Dialog in its default modal mode, Radix renders role="dialog" labelled by DialogTitle and described by DialogDescription, traps focus, closes on Escape, returns focus to the trigger, sets aria-hidden on the content outside the dialog, and locks page scroll. Alert Dialog uses role="alertdialog" with the same focus handling. You still have to render a title.
components/edit-profile.tsxtsx
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";

export function EditProfile() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Edit profile</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>Changes are visible to your team.</DialogDescription>
        </DialogHeader>
        {/* fields */}
        <DialogFooter>
          <Button type="submit">Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

What about the native dialog element?

The HTML <dialog> element opened with showModal() covers several items on this list natively: it renders in the top layer above everything else, makes the rest of the document inert, and closes on Escape. See the dialog element on MDN (opens in a new tab).

It does not do everything. You still need an accessible name (aria-labelledby on the element), a deliberate initial focus (the autofocus attribute on the right control), and your own scroll lock. Calling show() instead of showModal() opens a non-modal dialog without any of the modal behaviour. Test focus return on close in every browser you support rather than assuming it.

In React, the main friction is that <dialog> is imperative: you call showModal() from an effect when your open state changes, and sync state back from the close event. That is workable for simple cases. For dialogs that contain menus, popovers, or comboboxes, a primitive library that coordinates nested layers is usually less work.

How to test each item

Most of the checklist can be automated with Testing Library and user-event, which simulates real keyboard input:

edit-profile.test.tsxtsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
// toHaveFocus needs @testing-library/jest-dom in your test setup.

import { EditProfile } from "./edit-profile";

test("dialog is named, traps focus, and returns focus", async () => {
  const user = userEvent.setup();
  render(<EditProfile />);

  const trigger = screen.getByRole("button", { name: "Edit profile" });
  await user.click(trigger);

  // 1. Role and name
  const dialog = screen.getByRole("dialog", { name: "Edit profile" });

  // 2. Focus moved inside
  expect(dialog.contains(document.activeElement)).toBe(true);

  // 3. Tab stays inside
  await user.tab();
  await user.tab();
  await user.tab();
  expect(dialog.contains(document.activeElement)).toBe(true);

  // 4 and 5. Escape closes and focus returns
  await user.keyboard("{Escape}");
  expect(screen.queryByRole("dialog")).toBeNull();
  expect(trigger).toHaveFocus();
});

Add an automated scan with axe for structural problems, such as a missing name. Then do what automation cannot:

  • Open and close the dialog with only a keyboard, in each browser you support.
  • Use a screen reader, such as VoiceOver or NVDA, and confirm that the title is announced and that the virtual cursor cannot reach the page behind.
  • Zoom to 200% and on a narrow viewport check that every control is reachable.
  • Check focus indicators and text inside the dialog against its background with the contrast checker. The thresholds are in WCAG colour contrast for UI developers.

Aback UI documents which of these checks it has run and which are still pending on its accessibility page, including the keyboard behaviour of each component.

Frequently asked questions

Does an accessible modal need aria-modal='true'?

The WAI-ARIA Authoring Practices dialog pattern uses role='dialog' with aria-modal='true' for modal dialogs. Many libraries also hide the rest of the page with aria-hidden or the inert attribute, which makes the background unavailable to assistive technology even where aria-modal support is incomplete.

Where should focus go when a modal dialog opens?

Generally to the first focusable element inside the dialog. If the dialog starts with content that would scroll out of view, focus a static element at the top instead, and for a destructive confirmation consider focusing the least destructive action.

Should clicking the backdrop close a dialog?

It is a common convenience for ordinary dialogs, but it should never be the only way to close one, and it should not dismiss a confirmation that requires a decision. Escape and a visible close or cancel button are the reliable paths.

Is the native HTML dialog element accessible?

showModal() gives you a modal dialog in the top layer, makes the rest of the page inert, and closes on Escape. You still need to provide an accessible name, decide initial focus, and add scroll locking if you want it.

Share this post

Try Aback UI in your project

Accessible React components you install as source. MIT licensed.

Get started