Ch. 3 · React

React Dialogs: Focus, Escape and Restoration

Build an accessible React modal: native dialog with showModal, focus on open, Escape to close, focus restoration and testing with keyboard only.

~7 min readadvancedupdated Oct 6, 2026

“Build a modal dialog in React. What makes it accessible?” Interviewers for senior frontend roles ask this because a modal that looks right can still be unusable: keyboard users tab into the page behind it, screen reader users never learn that it opened, and closing it drops focus at the top of the document.

The answer they hope for lists the behaviours a modal must have and then shows how to get them, ideally starting with the native <dialog> element instead of rebuilding everything with divs.

Before you start

You should know refs and effects in React, the basics of keyboard navigation (Tab, Shift+Tab, Escape), and what ARIA attributes such as aria-labelledby do. The ARIA Authoring Practices modal dialog pattern is the reference for expected behaviour. The React code works in React 18 and 19; React 19 lets you pass ref as a prop to function components without forwardRef.

The short answer

An accessible modal must: move focus into the dialog when it opens; keep Tab and Shift+Tab inside it while open; make the rest of the page inert to pointer, keyboard and screen readers; close on Escape; have an accessible name; and return focus to the element that opened it when it closes. The native <dialog> element opened with showModal() provides containment, inertness of the background, Escape handling and top-layer rendering. In React you mainly need to call showModal() and close() from an effect, sync the close event back into state, label the dialog, and restore focus.

How it works

showModal() places the dialog in the browser’s top layer, above everything regardless of z-index, and makes the rest of the document inert. The browser moves focus into the dialog, and pressing Escape fires a cancel event followed by close.

import { useEffect, useId, useRef } from 'react';

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

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

  return (
    <dialog ref={ref} aria-labelledby={titleId} onClose={onClose}>
      <h2 id={titleId}>{title}</h2>
      {children}
      <button type="button" onClick={onClose}>Close</button>
    </dialog>
  );
}
JSX

React owns the open state; the effect translates it into the imperative showModal() and close() calls. When the user presses Escape, the browser closes the dialog and fires close, and onClose updates the parent’s state so React and the DOM agree again. Do not set the open attribute directly as a prop, because that opens the dialog non-modally, without inertness or Escape handling.

Step-by-step walkthrough

Step 1: Give the dialog a name and a sensible initial focus

aria-labelledby pointing to the heading lets screen readers announce “Delete project, dialog” when it opens. By default the browser focuses the first focusable element. For destructive confirmations, add autoFocus to the safest button, typically “Cancel”, so Enter does not delete by accident.

Step 2: Restore focus to the trigger

Browsers now restore focus to the previously focused element when a modal dialog closes, but behaviour has varied across versions, and when the trigger unmounts it fails. Capture the trigger explicitly:

useEffect(() => {
  if (!open) return;
  const trigger = document.activeElement;
  return () => {
    if (trigger instanceof HTMLElement && trigger.isConnected) trigger.focus();
  };
}, [open]);
JSX

When open becomes false, the cleanup runs and focus goes back to the button that opened the dialog. If that button was removed (say, the row was deleted), move focus to a logical place such as the list heading.

Step 3: Handle backdrop clicks deliberately

Clicking the backdrop of a native dialog targets the <dialog> element itself. If closing on backdrop click is desired:

<dialog
  ref={ref}
  onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
  <div className="dialog-body">{/* content */}</div>
</dialog>
JSX

Wrap content in an inner element with padding so clicks inside never target the dialog. For forms with unsaved data, consider not closing on backdrop click, or confirm first.

Step 4: Prevent accidental Escape when needed

If the dialog holds a half-filled form, intercept cancel:

<dialog onCancel={e => { if (isDirty) { e.preventDefault(); askToDiscard(); } }} />
JSX

Use this sparingly; Escape should usually close.

Step 5: Build with a div only if you must

If you cannot use <dialog>, you need role="dialog", aria-modal="true", a portal to the end of body, a focus trap that cycles Tab, the inert attribute on the app root, Escape handling and scroll locking. That is a lot to get right, which is why the native element or a tested library (Radix, React Aria, Headless UI) is usually the better answer. See portals and event bubbling for the portal details.

Worked scenario

A project management app had a custom “Delete project” modal built from a div with a dark overlay. An accessibility audit found four issues: keyboard users could Tab to buttons behind the overlay, screen readers did not announce the dialog, Escape did nothing, and after closing, focus went to the top of the page so keyboard users had to tab through the whole navigation to get back.

The rewrite used the Modal component above:

function DeleteProjectButton({ project, onDelete }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Delete</button>
      <Modal open={open} onClose={() => setOpen(false)} title={`Delete ${project.name}?`}>
        <p>This removes all tasks in the project.</p>
        <button autoFocus onClick={() => setOpen(false)}>Cancel</button>
        <button onClick={() => { onDelete(project.id); setOpen(false); }}>Delete project</button>
      </Modal>
    </>
  );
}
JSX

showModal() made the background inert and handled Escape. aria-labelledby gave the announcement. autoFocus on Cancel protected against accidental deletion, and the focus restoration effect returned users to the Delete button, or to the project list heading when the project was gone. The custom overlay and its z-index battles were deleted as well.

Common mistake

  • “It has a dark overlay, so it is modal.” Visual overlays do not stop keyboard focus or screen reader navigation.
  • Rendering <dialog open>. That shows a non-modal dialog without inertness or Escape handling. Call showModal().
  • Not syncing the close event. If Escape closes the dialog but React state still says open, the next click on the trigger does nothing because the effect sees no change.
  • Forgetting focus restoration. Users lose their place after every dialog.
  • Using aria-hidden on the app root while focus can still reach it. Hidden yet focusable content confuses screen readers; use inert, which showModal() applies for you.
  • Missing an accessible name. “Dialog” with no title tells users nothing.

Verify the behavior

Test with the keyboard and with React Testing Library. jsdom does not fully implement showModal, so stub it in tests:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

beforeAll(() => {
  HTMLDialogElement.prototype.showModal = function () { this.open = true; };
  HTMLDialogElement.prototype.close = function () {
    this.open = false;
    this.dispatchEvent(new Event('close'));
  };
});

test('restores focus to the trigger after closing', async () => {
  const user = userEvent.setup();
  render(<DeleteProjectButton project={{ id: 1, name: 'Apollo' }} onDelete={() => {}} />);
  const trigger = screen.getByRole('button', { name: 'Delete' });
  await user.click(trigger);
  expect(screen.getByRole('dialog', { name: 'Delete Apollo?' })).toBeInTheDocument();
  await user.click(screen.getByRole('button', { name: 'Cancel' }));
  expect(trigger).toHaveFocus();
});
JSX

Then test manually in a real browser: open with Enter, Tab through every control and confirm focus never leaves, press Escape, and confirm focus is back on the trigger. Repeat with VoiceOver or NVDA to hear the announcement. Automated tests catch regressions; manual checks catch behaviour jsdom cannot simulate, such as true inertness.

Follow-up questions

Should a dialog announce dynamic messages, like a save error? Use a live region inside or near the dialog. See accessibility announcements.

What about scroll locking? Modal dialogs do not stop the page behind them from scrolling by default. Add overflow: hidden on body while open, or use the CSS :has(dialog[open]) selector.

When is a non-modal dialog appropriate? For panels the user works alongside, like a find-and-replace box. Use show() instead of showModal() and do not trap focus.

Why not always use a library? Libraries are a good default for complex cases (nested dialogs, animations, older browser support). Knowing the underlying requirements lets you evaluate them and debug them.

Interview exercise

A modal opens from a row’s “Edit” button. After saving, the list re-renders, the row moves to the top (sorted by last modified), and focus disappears. What is going on, and how would you fix it?

Answer and reasoning

Focus was meant to return to the trigger, but the trigger either unmounted or was replaced when the list re-sorted, for example because rows are keyed by index or the edit button is conditionally rendered. Focusing a detached element does nothing, so focus falls to body. First, give rows stable keys (key={row.id}) so React moves the existing row and its button instead of recreating them; then restoring focus to the captured element works even after the reorder. Second, add a fallback: if trigger.isConnected is false, focus the row’s new edit button by id or the list heading. Finally, consider announcing “Saved” in a live region so screen reader users get confirmation of what happened.

Continue learning

Continue with the React interview questions and the React MCQs. Related notes: portals and event bubbling, accessibility announcements and list keys and identity. Official references: the ARIA modal dialog pattern and the createPortal reference on react.dev.

More in React

read ✓React · hard

React Context Splitting for Performance

Why every React context consumer re-renders when any part of the value changes, and how splitting state, actions and slow-changing data fixes it.

~6 min readread →
esc