Skip to main content

Developer Guides

Accessible Modal Dialogs: Focus Trap, Inertness, and Escape

Last updated

A modal dialog is one of the most common interactive patterns on the web and one of the most consistently broken. The pattern itself is well-defined — the W3C ARIA Authoring Practices dialog pattern spells out exactly what an accessible dialog has to do. The trouble is that most teams ship a custom <div role="dialog"> and forget at least one of the four hard requirements: focus moves into the dialog on open, focus restores to the trigger on close, Esc closes the dialog, and background content is inert. Miss any of those and your modal is broken for keyboard and screen-reader users.

The good news is that 2026 is the first year you can credibly tell a team to stop writing custom dialog code at all. The native HTML <dialog> element has been stable in every evergreen browser for years, and showModal() handles the four requirements above without you writing a focus trap. This guide shows the native path first, the ARIA fallback second, and the most common DIY bugs we still see in production.

The keyboard contract a modal must satisfy

Before any code, the contract. A modal dialog is accessible only if every one of the following is true the moment it opens and the moment it closes:

  • Focus moves in on open. Either the first focusable element in the dialog, or a designated initial-focus target (commonly the primary action button or the close button — the APG pattern allows either). Screen-reader users who do not see focus must be told the dialog opened, and moving focus is how that announcement happens.
  • Esc closes the dialog. No exceptions. Confirmation dialogs may want to ask “are you sure” first, but they still close on Esc from the confirmation step.
  • Focus restores to the trigger on close. If a button opened the dialog, focus returns to that button. If the trigger was destroyed (rare), focus moves to a sensible nearby element — never to <body>.
  • Tab cycles within the dialog only. This is the designed focus trap. Past the last focusable element, focus wraps to the first; before the first, Shift+Tab wraps to the last.
  • Background content is inert. Not reachable via Tab, not announced by assistive technology, not click-through. The inert attribute is the one-line way to get this right.

Item four is the famous “focus trap” — and it is designed behavior, not a defect. Confusing it with a keyboard trap (WCAG SC 2.1.2) is the most common audit-report error we see. The distinction lives in our keyboard traps guide if you need to brief a stakeholder.

Native <dialog> — the modern default

The shortest correct modal in 2026 is also the most accessible one. The native <dialog> element exposes a showModal() method that handles every item in the keyboard contract above. Browsers move focus into the dialog, capture Tab inside it, dismiss on Esc, and render a ::backdrop pseudo-element that makes the rest of the page non-interactive.

confirm-dialog.htmlhtml
<button id="open" type="button">Delete account</button>

<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete your account?</h2>
  <p>This cannot be undone.</p>
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="confirm">Delete</button>
  </form>
</dialog>

<script>
  const dialog = document.getElementById('confirm');
  document.getElementById('open').addEventListener('click', () => {
    dialog.showModal();           // <- focus moves in, Tab is trapped, Esc closes
  });
  dialog.addEventListener('close', () => {
    // dialog.returnValue === 'cancel' | 'confirm'
    // Focus restores to the trigger automatically in every evergreen browser.
  });
</script>

Two details worth knowing. First, the method="dialog" form is a native shortcut — clicking any submit button inside it closes the dialog and stores the button's value in dialog.returnValue. Second, focus restoration to the triggering element is built in to every modern browser, but verify it with NVDA + Firefox and VoiceOver + Safari on your specific stack before you ship — there are still edge cases when the dialog is rendered inside a Shadow DOM or a React portal.

When you cannot use <dialog> — the ARIA path

Sometimes the native element is not an option: an animation library forces a wrapper, a design system standardized on divs before native dialogs were stable, or you need to portal the markup to a non- body container. The fallback is a role="dialog" with aria-modal="true" and manual focus management. You take on every responsibility the native element handled for you.

Modal.tsxtsx
function Modal({
  open,
  onClose,
  triggerRef,
  children,
}: {
  open: boolean;
  onClose: () => void;
  triggerRef: React.RefObject<HTMLElement>;
  children: React.ReactNode;
}) {
  const dialogRef = useRef<HTMLDivElement>(null);

  // Move focus in on open; restore to trigger on close.
  useEffect(() => {
    if (!open) return;
    const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    firstFocusable?.focus();

    // Inert the background while open.
    const main = document.querySelector('main');
    main?.setAttribute('inert', '');

    return () => {
      main?.removeAttribute('inert');
      triggerRef.current?.focus();
    };
  }, [open, triggerRef]);

  // Esc closes.
  useEffect(() => {
    if (!open) return;
    function onKey(e: KeyboardEvent) {
      if (e.key === 'Escape') onClose();
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  if (!open) return null;
  return (
    <div
      ref={dialogRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <h2 id="modal-title">Confirm</h2>
      {children}
      <button type="button" onClick={onClose}>Close</button>
    </div>
  );
}

Two things this snippet does not implement and a real library should: a Tab-cycle focus trap that wraps from the last focusable element back to the first, and a click-outside-to-dismiss handler that is paired with Esc so keyboard users get the same affordance. If you find yourself implementing both from scratch, that is your cue to either switch to native <dialog> or adopt a primitive library — covered below.

The most common DIY bug: focus never moves in

The single most common modal bug we flag in audits is “the modal renders, but focus stays on the page behind it.” Sighted users see the modal and use the mouse. Screen-reader users have no idea anything changed. Keyboard users are now tabbing through the page under the modal, which still has all of its tab stops.

Before — opens, but does not move focus

BrokenModal.tsx — beforetsx
function BrokenModal({ open, onClose, children }) {
  if (!open) return null;
  return (
    <div className="overlay">
      <div role="dialog" aria-modal="true">
        {children}
        <button type="button" onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

The component renders the right ARIA roles but never tells the browser to move focus, never inerts the background, and ignores Esc. It is, in practice, a styled div pretending to be a dialog.

After — switch to native <dialog>

FixedModal.tsx — aftertsx
function FixedModal({
  onClose,
  children,
}: {
  onClose: () => void;
  children: React.ReactNode;
}) {
  const ref = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    ref.current?.showModal();        // focus in, Tab trap, Esc, backdrop
    return () => ref.current?.close();
  }, []);

  return (
    <dialog ref={ref} onClose={onClose} aria-labelledby="dlg-title">
      <h2 id="dlg-title">Confirm</h2>
      {children}
      <form method="dialog">
        <button>Close</button>
      </form>
    </dialog>
  );
}

One showModal() call replaces three custom effects, a focus-trap utility, and a background-inertness hack. If you cannot migrate to <dialog> today, the equivalent fix is the explicit focus-management useEffect from the ARIA-path section above — just make sure all four contract items are covered.

The inert attribute

When you take the ARIA fallback path, the cleanest way to make the rest of the page non-interactive is the inert attribute. It does two things at once: removes the subtree from the keyboard tab order, and hides it from the accessibility tree so screen readers skip past it. MDN confirms support in Safari, Chrome, and Firefox.

inert-background.htmlhtml
<body>
  <header>...</header>
  <!-- Everything outside the dialog is inert while it is open. -->
  <main inert>
    <article>...</article>
  </main>
  <footer inert>...</footer>

  <div role="dialog" aria-modal="true" aria-labelledby="title">
    <h2 id="title">Inert background pattern</h2>
    <button type="button">Close</button>
  </div>
</body>

In React, set inert imperatively on the siblings of the dialog when it opens, and remove it on close — see the useEffect in the ARIA-path snippet above. Native <dialog> handles this for you via the top-layer; the manual approach is only needed on the fallback path.

Common pitfalls

  • Calling dialog.show() instead of showModal(). The non-modal variant renders the dialog but skips the focus trap, the backdrop, and Esc-to-close. It is fine for non-modal use cases like a side panel, wrong for confirmation dialogs.
  • No aria-labelledby or aria-label. Screen readers announce a dialog by its accessible name. A dialog with no label is announced as “dialog,” which tells the user nothing about what just opened.
  • Icon-only close button without a name. <button>×</button> is unlabelled. Use <button aria-label="Close">×</button> or include visible text.
  • Closing on backdrop click with no keyboard equivalent. Click-outside-to-dismiss is fine, but it must be paired with Esc-to-close. Otherwise keyboard users have no dismiss affordance and you have shipped a SC 2.1.2 violation.
  • Restoring focus to the wrong element on close. Save the triggering element in a ref before opening; restore to it in the close handler. Never let focus fall back to <body>.

React Aria, Radix, Headless UI

If you are committed to a custom-styled modal and a complex interaction model — multi-step flows, animation choreography, nested dialogs, portal-based layout — a primitive library is the right call. React Aria, Radix UI, and Headless UI all ship dialog primitives that get focus management, accessible-name wiring, and portal handling correct out of the box. Animation hooks integrate cleanly with Framer Motion or CSS transitions, and the dismiss contract is enforced.

react-aria-dialog.tsxtsx
import { Dialog, DialogTrigger, Modal, Heading, Button } from 'react-aria-components';

export function ConfirmDialog() {
  return (
    <DialogTrigger>
      <Button>Delete account</Button>
      <Modal isDismissable>
        <Dialog>
          {({ close }) => (
            <>
              <Heading slot="title">Delete your account?</Heading>
              <p>This cannot be undone.</p>
              <Button onPress={close}>Cancel</Button>
              <Button onPress={close}>Delete</Button>
            </>
          )}
        </Dialog>
      </Modal>
    </DialogTrigger>
  );
}

The rule of thumb: if your modal is a confirmation, a form, or a one-screen flow, <dialog> + a few lines of script is the smallest correct option. If it is a multi-step flow with animation requirements and you are already in a React app, reach for a primitive library before writing the focus trap yourself. Our accessible React guide and accessible Next.js guide cover the broader framework patterns that compose with this one.

Frequently asked questions

Is the native <dialog> element ready for production in 2026?
Yes — confidently. Safari, Firefox, and Chrome have shipped <dialog> with showModal(), the ::backdrop pseudo-element, and built-in focus management for years, and the remaining edge cases (Shadow DOM, React portals, focus-restore on close) are well-documented. The only reasons to skip it in 2026 are an existing design system that standardized on role="dialog" divs before native was stable, or an animation library that does not support animating the top layer. For a greenfield modal, default to <dialog>; the cost-benefit favors it on every dimension that matters.
What is the difference between a focus trap and a keyboard trap?
A focus trap is the designed pattern inside an open modal dialog: Tab cycles between focusable elements within the dialog, and the user dismisses with Escape or an accessible close button. That is the W3C ARIA Authoring Practices guidance and it is correct behavior. A keyboard trap is a WCAG 2.1.2 violation — the user enters a component (commonly a date picker, an iframe, or a broken modal) and cannot escape with Tab, Shift+Tab, Escape, or arrow keys. The same focus-cycling behavior is correct in a working modal and a violation in a broken one; what distinguishes them is whether a documented escape exists. Our keyboard traps guide covers the testing recipe in detail.
Should I use Radix, React Aria, or write my own?
For a confirmation or single-screen modal, write the native <dialog> element directly — it is fewer lines than configuring a library and it inherits browser-correct behavior for free. For a complex flow (multi-step wizard, nested dialogs, animation choreography), a primitive library like React Aria or Radix is the right call: they ship the focus management, accessible-name wiring, and portal handling correctly and let you style the result however you want. Writing your own focus trap from scratch in 2026 is rarely the right answer — the libraries exist because everyone keeps getting at least one of the four contract items wrong.
Does SweepHound detect missing focus management on modal dialogs?
Partially. Our dual-engine scan flags missing role="dialog", missing aria-modal="true", and dialogs with no accessible name (no aria-labelledby or aria-label). It also flags icon-only close buttons with no accessible name and detects positive tabindex values that suggest a botched manual focus trap. What no automated scanner can determine is whether focus actually moves into the dialog on open, restores to the trigger on close, or whether Escape actually dismisses — those require a human at a keyboard. We flag the structural problems and mark the rest as manual-review items with a link back to this guide.

How SweepHound supports modal auditing

We will be honest about what automation can and cannot tell you here. SweepHound's scan flags the structural problems reliably: a role="dialog" with no aria-modal="true", a dialog with no accessible name, a close button with no accessible name, and suspicious tabindex patterns that suggest a hand-rolled focus trap. The deterministic remediation engine then emits before/after code for the fixable cases.

What it cannot tell you is whether focus actually moves into the dialog when it opens, whether Esc actually closes it, or whether focus restores to the right element on close. Those are runtime behaviors and they need a keyboard, which is why every modal on your site should appear in the manual pass of our manual accessibility checklist. The scan narrows the work; it does not replace it.

To see what a dual-engine scan flags on your modals, start a free scan — the manual modal checklist is included on every tier. Paid plans add scheduled re-scans, authenticated scanning, and the statement generator; see pricing for details. And if you want a no-account second opinion on a single page, our sign-up flow gives you one scan immediately.

Sources

  1. W3C ARIA Authoring Practices, Dialog (Modal) PatternPrimary W3C reference for the accessible modal-dialog pattern.
  2. MDN, <dialog>: The Dialog elementNative API reference: showModal(), close(), the ::backdrop pseudo-element, and browser support.
  3. MDN, ARIA: dialog roleARIA role reference for role="dialog" + aria-modal="true" fallback path.
  4. MDN, inert global attributeThe one-line attribute for inerting background content while a dialog is open.
  5. Scott O’Hara, Use the dialog element (reasonably)Practitioner write-up on real-world <dialog> behavior and edge cases.