# Accessible Modals: From dialog showModal() to Focus Traps

> Compare dialog showModal() with a div modal: inert, focus trap, ESC, scroll lock, focus restoration, and why Tab leaves a dialog (measured in Chrome).

**Published:** 2026-10-24 | **Updated:** 2026-10-24

---


You built a modal. You press Tab, and the buttons in the background get focus. You press ESC, and nothing closes. A screen reader plows through the content without even saying "dialog." And when you click the close button, focus vanishes into thin air.

Anyone who builds modals runs into all four of these at some point. The name **focus trap** sounds great, but the problem is that the trap tends to get set in the wrong place. Let's fix them one by one.

## Modal accessibility: what to get right

On screen, a modal is just a box. For someone using a keyboard or a screen reader, it comes with a few promises. When it opens, focus moves inside. The background behind it can't be touched. ESC closes it. When it closes, focus returns to the button that opened it. A screen reader announces that it's a dialog and reads its title. And visually, the page behind it doesn't scroll along.

The good news is that there's a way to get the browser to keep most of those promises for you.

## The dialog element and showModal(): what the browser does for you

Open a `<dialog>` element with `showModal()` and most of the work is done. Safari 15.4 and Firefox 98 joined in March 2022, so every major browser supports it.

{{< img src="images/contents/dialog-vs-div.png" alt="Comparing the dialog element with a hand-built div: one showModal() call gives you focus movement, ESC closing, a backdrop, an inert background, and the dialog role, leaving only scroll locking and remembering the opener. With a div, you have to build the role and aria-modal, the focus trap, ESC, the inert background, scroll locking, and focus restoration yourself" >}}

```html
<h2 id="list-heading" tabindex="-1">Items</h2>
<button type="button" id="open-modal-btn">Delete item</button>

<dialog id="confirm-modal" aria-labelledby="modal-title">
  <h2 id="modal-title">Delete this item?</h2>
  <p>This cannot be undone.</p>
  <div class="modal-actions">
    <button type="button" id="modal-cancel">Cancel</button>
    <button type="button" id="modal-confirm">Delete</button>
  </div>
</dialog>
```

When you open it with `showModal()`, the browser does this for you.

- Moves focus into the modal (usually to the first focusable element)
- Closes it when you press ESC
- Draws a background overlay with the `::backdrop` pseudo-element
- Makes the page outside the modal `inert`. An `inert` region can't be focused, can't be clicked, and can't be reached by screen reader navigation. It's still visible, but it's treated as if it weren't there
- On close, returns focus to whatever had it at the moment `showModal()` was called

Open it with `show()` and none of this happens. It has to be `showModal()`. In React, opening it with the `open` attribute, as in `<dialog open={isOpen}>`, behaves like `show()` and doesn't block the background, so use a ref and call `showModal()` and `close()` instead.

There are two things the browser doesn't do for you. One is **locking the background scroll**, and the other is **remembering the opener**. Safari (both Mac and iOS) doesn't give a button focus when you click it. So for a dialog opened with the mouse, the "focus at the moment of opening" isn't the opener button, and when you close the dialog, focus lands somewhere random (this is [filed as a WebKit bug](https://bugs.webkit.org/show_bug.cgi?id=229895) and is still open as of October 2026). Remembering the opener yourself fixes it.

The code below also uses the scroll lock functions (`lockScroll` and `unlockScroll`, covered in the next section). Put both pieces of code in the same `<script>`, right before `</body>`.

```javascript
const modal = document.getElementById('confirm-modal');
const openBtn = document.getElementById('open-modal-btn');
let opener = null;

openBtn.addEventListener('click', () => {
  opener = openBtn;   // remember the opener ourselves, for Safari
  modal.showModal();  // showModal(), not show()
  // the browser doesn't lock scrolling for us (next section)
  lockScroll();
});

document.getElementById('modal-cancel')
  .addEventListener('click', () => modal.close());
document.getElementById('modal-confirm')
  .addEventListener('click', () => {
    // The actual delete logic goes here
    modal.close();
  });

// Whether it closes via Cancel, Delete, or ESC, the close
// event fires once
modal.addEventListener('close', () => {
  unlockScroll();
  // Try to send focus back to the opener; if it was removed or hidden
  // and can't take focus, use the list heading
  opener?.focus();
  if (document.activeElement !== opener) {
    document.getElementById('list-heading').focus();
  }
});
```

The key is to do the cleanup in one place, the `close` event, instead of attaching it to each button. ESC closes the dialog without going through any button click code. If you only release the scroll lock in the Cancel handler, a user who closes with ESC is left with a locked page. The Android back gesture also closes a dialog, and that doesn't go through a button either.

The last `if` is for cases like "Delete item," where the row containing the opener is removed, or a row menu closes and hides the button. A button like that can't take focus even if you call `focus()` on it, so you send focus somewhere nearby, like the list heading. If the target is an element like a heading that doesn't normally take focus, give it `tabindex="-1"` so `focus()` works. If you send it nowhere, focus falls to `body`, and the next Tab sends a keyboard user to some random place.

### Tab leaving a showModal() dialog for the address bar is normal

In a modal opened with `showModal()`, if you press Tab once more after the last button, focus doesn't go back to the first button in the modal. It **goes out to browser UI such as the address bar** (confirmed in Chrome 155). At first glance it looks like the trap is leaking, and you want to plug it with JavaScript. But leaving it alone is the right call.

A proposal to block exactly this was filed with WHATWG, which maintains the HTML spec ([issue #8339](https://github.com/whatwg/html/issues/8339)), but it settled on keeping the current behavior and was closed in November 2023. The W3C Accessible Platform Architectures group (APA) weighed in on the decision. Keyboard users should be able to open a new tab or change browser settings while a modal is showing, and some environments, like kiosks, need a way out. What you have to guarantee is **no leaking into the background page**, and `inert` already does that.

The W3C's ARIA Authoring Practices Guide (APG) modal pattern says "Tab cycles within the dialog," which can be confusing. But the APG pattern is for dialogs you build from a `div`, and it doesn't cover the `<dialog>` element. So this post leaves the browser's behavior alone for `<dialog>`, and adds the cycling per the APG for the hand-built `div` modal.

## Locking modal scroll: shared by dialog and div

When the page behind an open modal scrolls, it's easy to lose track of where you are. This is something you have to do yourself even with `<dialog>`.

```javascript
// The simple way: works most of the time, but on iOS Safari the
// background can still scroll document.body.style.overflow = 'hidden';

// A more reliable way: pin the body at the current position
function lockScroll() {
  const scrollY = window.scrollY;
  document.body.style.position = 'fixed';
  document.body.style.top = `-${scrollY}px`;
  document.body.style.width = '100%';
}

function unlockScroll() {
  const top = document.body.style.top;
  document.body.style.position = '';
  document.body.style.top = '';
  document.body.style.width = '';
  // Restore the original position. Without instant, sites with
  // scroll-behavior: smooth will roll down to it
  window.scrollTo({
    top: parseInt(top || '0', 10) * -1,
    behavior: 'instant'
  });
}
```

Pinning with `position: fixed` resets the scroll position to the top, so you store the position in `top` when opening and restore it when closing. The iOS Safari problem where `overflow: hidden` leaked was largely fixed in Safari 26.4, but [similar symptoms are still reported separately](https://bugs.webkit.org/show_bug.cgi?id=317941), and plenty of people are on earlier versions, so `position: fixed` is the safer choice. Mixing the two approaches throws off the restoration, so pick one.

On Windows, where the scrollbar takes up space, locking the scroll makes the scrollbar disappear and the content shifts sideways by its width (this doesn't happen with overlay scrollbars, like the Mac default). A common fix is `html { scrollbar-gutter: stable; }`, which always reserves room for the scrollbar, but I haven't verified this in such an environment. Please open and close a modal on Windows and see whether the content moves.

## Build it yourself or use a library?

Before building a modal, choose in this order.

1. If you can use `<dialog>` and `showModal()`, use that. Most cases end here.
2. If you use a framework like React and need a modal that fits your design system, use the dialog from a library that manages accessibility for you. React Aria (Adobe), Radix UI, and Headless UI have well-known Dialog components, and if you only need a trap without a framework, there's the [focus-trap](https://github.com/focus-trap/focus-trap) package.
3. Only if neither works, build it from a `div`.

The rest of this post is about option 3, building it yourself. Even if you never need to, it's worth reading. Knowing what a library or `<dialog>` does behind the scenes tells you where to look when something breaks.

When you build it yourself, you attach five pieces in order: markup and ARIA (1), blocking the background with `inert` (2), the focus trap (3), opening, closing, and focus restoration (4), and wiring up ESC and the buttons (5). The scroll lock functions from earlier go along with them. Put all the JavaScript in a single `<script>` right before `</body>`.

## role="dialog" and aria-modal: announcing a dialog (build it yourself 1)

If it isn't a `<dialog>`, you have to tell assistive technology with ARIA that this is a dialog. Make the modal `div` a direct child of `<body>` (the next section explains why).

```html
<body>
  <main>
    <h2 id="custom-list-heading" tabindex="-1">Items</h2>
    <button type="button" id="open-custom-modal">Delete item</button>
    <!-- The rest of the page content -->
  </main>

  <div
    id="custom-modal"
    role="dialog"
    aria-modal="true"
    aria-labelledby="custom-modal-title"
    aria-describedby="custom-modal-desc"
    tabindex="-1"
    hidden
  >
    <h2 id="custom-modal-title">Confirm deletion</h2>
    <p id="custom-modal-desc">
      The selected item will be deleted. This cannot be undone.
    </p>
    <button type="button" id="custom-modal-cancel">Cancel</button>
    <button type="button" id="custom-modal-confirm">Delete</button>
  </div>
</body>
```

- `role="dialog"`: "this is a dialog"
- `aria-modal="true"`: "everything outside this dialog is unavailable right now"
- `aria-labelledby`: links the dialog's name to its title element
- `aria-describedby`: links the body description (optional)
- `tabindex="-1"`: lets the modal itself take focus when it contains nothing focusable

When the modal opens, a screen reader usually announces the title and role, something like "Confirm deletion, dialog," and then reads the "Cancel" button that received focus. That said, the reading order and wording differ between screen readers, and I didn't measure this by actually listening. VoiceOver on Apple devices also has open `<dialog>` bugs. On iOS, a dialog with `aria-labelledby` opened by `showModal()` isn't announced, and on Mac, elements inside a dialog can't be interacted with (see WebKit bugs 273846 and 302351 in the references).

`aria-modal="true"` is only a **declaration** to the screen reader. It doesn't actually block the background. What actually blocks it is `inert` in the next section.

> If you want to know more about when to use `role` and `aria-*` and when not to, see [The Golden Rule of ARIA in the ARIA Practical Guide]({{< relref "/posts/aria-practical-guide#1-the-golden-rule-of-aria-when-to-use-it-when-not-to" >}}).

Decide where the first focus goes, too. For a modal that asks about an action that can't be undone, focus should land on "Cancel," not "Delete," so an absent-minded Enter does nothing. In this example "Cancel" comes first, so that's what happens. Strictly speaking, a warning window that demands a response, like a delete confirmation, fits `role="alertdialog"` better, and in that case you must link the warning text with `aria-describedby`. I used `role="dialog"` in this post so it can be compared side by side with `<dialog>` in the same role.

Here's a minimal style that centers it on the screen.

```css
#custom-modal {
  position: fixed;
  inset: 0;
  margin: auto;
  box-sizing: border-box;
  width: min(400px, 90vw);
  height: fit-content;
  max-height: calc(100dvh - 2rem);  /* if it's taller than the screen */
  overflow-y: auto;                 /* scroll inside the modal */
  padding: 24px;
  background: #fff;
  /* shows up as a border in high contrast mode */
  border: 1px solid transparent;
  border-radius: 8px;
  /* darkens everything outside */
  box-shadow: 0 0 0 100vmax rgb(0 0 0 / 0.5);
}
```

Without `max-height` and `overflow-y`, buttons in a long modal end up off screen for people on mobile or zoomed far in. The page behind is scroll-locked, so they can never reach those buttons. The transparent border is for Windows high contrast mode. In that mode `box-shadow` disappears, but a transparent border turns into a line in a system color, which keeps the modal's edge visible ([Supporting High Contrast Mode Properly]({{< relref "/posts/high-contrast-mode" >}})).

There's also a reason `display` isn't set here. If you write `#custom-modal { display: flex; }`, the CSS beats the `hidden` attribute, and the modal stays visible even after closing. If you need `display`, add `#custom-modal[hidden] { display: none; }` along with it.

## Blocking the background with inert (build it yourself 2)

If `aria-modal` is a declaration, `inert` is the real lock. An element with `inert`, and everything inside it, can't be focused, clicked, or reached by screen reader navigation. Every major browser has supported it since Firefox 112 in April 2023.

{{< img src="images/contents/background-inert.png" alt="Three ways to handle the background when a modal opens: aria-modal is only a declaration and blocks nothing, inert blocks focus, clicks, and reading, and scroll lock keeps the page behind from shifting (on iOS, position fixed is the safe choice). showModal covers the first two for you, but you still lock scrolling yourself" >}}

```javascript
function setInert(modal, on) {
  // Among the direct children of body, keep only the one that contains
  // the modal and lock the rest
  const keep = Array.from(document.body.children)
    .find((el) => el.contains(modal));

  for (const el of document.body.children) {
    if (el !== keep) el.inert = on;
  }
}
```

Because it finds the element containing the modal and leaves it out, even a structure like a React portal that renders the modal inside `<div id="modal-root">` keeps only `modal-root` and locks the rest. On the other hand, if the modal is deep inside `<main>`, the whole `<main>` is kept and most of the background stays open. That's why the modal goes directly under `<body>` (or in the portal container).

This section is actually the most important one. **With `inert` alone, Tab won't leak into the background.** The whole background drops out of the Tab order, so focus only moves between the buttons in the modal and the browser UI. That's exactly how a `<dialog>` opened with `showModal()` behaves. The focus trap in the next section adds the "stay inside the modal" loop on top of this.

## Focus trap implementation: cycling Tab inside the modal (build it yourself 3)

The APG modal pattern recommends that Tab on the last element goes to the first, and Shift+Tab on the first goes to the last. Focus circles inside the modal instead of leaving for the browser UI. The two functions in this section are used as is by the `Modal` class in the next section, so even if you skip the explanation, do copy the code.

{{< img src="images/contents/focus-trap-diagram.png" alt="Diagram of the focus trap loop: in an example modal with an input, Cancel, and Delete, Tab moves through them in order and wraps from the last element back to the first, while Shift Tab goes the other way. The background buttons outside are locked by inert, so Tab never reaches them" >}}

First, a function that picks out "elements Tab can reach."

```javascript
// Candidates for elements Tab can reach
const FOCUSABLE_SELECTOR = [
  'a[href]',
  'area[href]',
  'button:not([disabled])',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  'iframe',
  'audio[controls]',
  'video[controls]',
  'summary',
  '[contenteditable]:not([contenteditable="false"])',
  '[tabindex]',
].join(',');

function getFocusableElements(container) {
  return Array.from(
    container.querySelectorAll(FOCUSABLE_SELECTOR)
  ).filter(
    (el) =>
      // tabindex="-1" is out of the Tab order
      el.tabIndex >= 0 &&
      // exclude anything inside an inert region
      !el.closest('[inert]') &&
      // display:none or hidden means no box
      el.getClientRects().length > 0 &&
      // also exclude elements that take space but are invisible
      getComputedStyle(el).visibility !== 'hidden'
  );
}
```

Without `input:not([type="hidden"])`, a hidden input that forms commonly contain (like a CSRF token) gets picked as the "first element," and the focus move fails entirely. The last two lines filter out elements that aren't on screen, for the same reason.

Next is the trap.

```javascript
function createFocusTrap(modal) {
  function handleKeyDown(e) {
    // Leave keys alone if other code in the modal (like an editor that
    // inserts tab characters) already handled them
    if (e.key !== 'Tab' || e.defaultPrevented) return;

    // Some modals change their content, so recompute on every Tab press
    const focusable = getFocusableElements(modal);

    // If there's nothing to focus, hold focus on the modal itself
    // (tabindex="-1")
    if (focusable.length === 0) {
      e.preventDefault();
      modal.focus();
      return;
    }

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

    // If focus is outside the modal or on the modal box itself, bring
    // it to the inner edge
    if (!modal.contains(active) || active === modal) {
      e.preventDefault();
      (e.shiftKey ? last : first).focus();
      return;
    }

    if (e.shiftKey && active === first) {
      // Shift+Tab: from the first element to the last
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && active === last) {
      e.preventDefault(); // Tab: from the last element to the first
      first.focus();
    }
  }

  // Attach to document, not the modal, because we need to catch Tab
  // even when focus is outside the modal
  document.addEventListener('keydown', handleKeyDown);

  // Return a cleanup function
  return () => document.removeEventListener('keydown', handleKeyDown);
}
```

There's a reason the listener goes on `document` instead of the modal. If it's only on the modal, then when focus happens to be outside the modal (say, on `body`), the Tab key event never reaches the modal and the trap is ignored entirely.

> The basics of Tab order, `tabindex`, and focus indicators are covered separately in [Core Concepts: Focus and Tab Order in Keyboard Accessibility A to Z]({{< relref "/posts/keyboard-accessibility-a-to-z#core-concepts-focus-and-tab-order" >}}).

## Focus restoration: back to the opener on close (build it yourself 4)

> The same restoration pattern is used in SPA routing, too. It's a companion to [the Focus Restoration Pattern in Focus Management in SPAs]({{< relref "/posts/spa-focus-management#focus-restoration-pattern" >}}).

This is a class that ties together the functions so far. When you close it, focus goes back to the button that opened the modal. That way you never lose track of "where you were."

```javascript
class Modal {
  constructor(modalEl) {
    this.modal = modalEl;
    this.opener = null;          // the element that opened the modal
    this.removeFocusTrap = null;
    this.isOpen = false;
  }

  open(triggerEl) {
    if (this.isOpen) return;     // don't open twice

    // Remember the opener. Safari doesn't give a button focus when you
    // click it, so trusting document.activeElement alone might
    // remember body
    this.opener = triggerEl || document.activeElement;

    this.modal.hidden = false;
    setInert(this.modal, true);
    lockScroll();
    this.removeFocusTrap = createFocusTrap(this.modal);

    // Focus the first focusable element, or the modal itself if
    // there is none
    const first = getFocusableElements(this.modal)[0];
    (first || this.modal).focus();

    this.isOpen = true;
  }

  // fallbackEl: where to put focus if the opener disappeared while the
  // modal was closing
  close(fallbackEl) {
    if (!this.isOpen) return;
    this.isOpen = false;

    this.removeFocusTrap?.();
    setInert(this.modal, false);
    unlockScroll();
    this.modal.hidden = true;

    // Try to send focus back to the opener; if it was removed or hidden
    // and can't take focus, use the fallback
    this.opener?.focus();
    if (document.activeElement !== this.opener) fallbackEl?.focus();
  }
}
```

The order matters. When opening, you have to remove `hidden` first so `getFocusableElements` can find the buttons in the modal (while it's hidden it has no box, so everything is filtered out). When closing, you have to lift `inert` first so you can give focus back to the opener in the background.

`close(fallbackEl)` does the same job as the last `if` in the `<dialog>` example. For actions like delete, where the opener can disappear or get hidden, pass in somewhere else to go (that element needs `tabindex="-1"` too).

## Closing with ESC and wiring up the buttons (build it yourself 5)

Finally, we connect the buttons and ESC.

```javascript
const customModal = new Modal(document.getElementById('custom-modal'));
const openCustomBtn = document.getElementById('open-custom-modal');
const listHeading = document.getElementById('custom-list-heading');

openCustomBtn.addEventListener('click',
  () => customModal.open(openCustomBtn));
document.getElementById('custom-modal-cancel')
  .addEventListener('click', () => customModal.close());
document.getElementById('custom-modal-confirm')
  .addEventListener('click', () => {
    // The actual delete logic goes here (the opener may disappear,
    // so pass in a place to go instead)
    customModal.close(listHeading);
  });

// Close on ESC, but only while open, and only if something like a list
// inside the modal didn't already use it
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && !e.defaultPrevented && customModal.isOpen) {
    customModal.close();
  }
});
```

Say the modal has an autocomplete list in it. If a user presses ESC meaning to close only that list and the whole modal closes, that's a problem. The `!e.defaultPrevented` condition prevents it (assuming the list's code calls `preventDefault()`).

By default, ESC should mean the same thing as "Cancel." In a confirmation modal, pressing ESC closes it without deleting anything. If the modal contains a form in progress and closing would throw the content away, don't block ESC. Instead, ask once more inside the same modal with a confirmation like "Your changes will be lost."

Put the markup and CSS from build it yourself 1 together with the scroll lock functions and the JavaScript from build it yourself 2 to 5, and you have a modal that works as is. I ran this assembled version in Chrome 155: Tab and Shift+Tab cycled only through the two buttons in the modal, and even when I deliberately moved focus out to `body`, the next Tab brought it back into the modal. Closing with ESC returned focus to the opener and released both `inert` and the scroll lock, and opening and closing at a position 2,359px down the page returned to that same position.

### What this code assumes

To keep it short, I left out a few cases on purpose. If any of them apply to you, `<dialog>` or a library is the safer choice.

- Only one modal is open at a time. If you open a modal on top of a modal, the second modal gets locked by `inert` too (`showModal()` keeps only the topmost one alive even when stacked).
- Elements added to `<body>` while the modal is open (like toast notifications) are not locked.
- Radio button groups, inputs inside `<fieldset disabled>`, and elements inside a closed `<details>` can make `getFocusableElements` count differently from the real Tab order. If one of these is at the very start of the modal, the first focus goes wrong, and if it's at the very end, the cycling goes wrong, so open the modal and press Tab on the last element yourself to check.
- When focus enters an `<iframe>` inside the modal, key presses don't reach the parent document, so the trap and ESC don't work in there.
- Dismiss gestures like Android's back gesture are only handled by `<dialog>`. A `div` modal only handles the ESC key.

## div modal vs dialog, measured: pressing Tab

Pressing Tab a few times beats reading an explanation of focus traps. In the [modal accessibility comparison demo](https://isaaceryn.github.io/demo_codes/modal-dialog-implementation/?lang=en), I put a modal built from a `div` with no handling at all (labeled "Bad modal") next to one built with `<dialog>.showModal()` ("Good modal"). Open a modal and keep pressing Tab. The bottom of the screen shows live whether focus is currently in the modal or in the background.

The demo's `div` modal is a control group that has **none** of build it yourself 1 to 5 applied. No role, no `inert`, no trap, no ESC, no restoration: I made it that way on purpose to show what that state looks like from the keyboard. These are the results of operating that demo with the keyboard in Chrome 155 (October 11, 2026). I sent key presses with Playwright and measured five times, and for "outside the page" I also pressed the keys by hand in a browser window to confirm that focus leaves the page (`blur`).

| Action | `div` modal (no handling) | `<dialog>.showModal()` |
|---|---|---|
| Focus right after opening by keyboard | Still on the opener (background) | "Cancel" in the modal |
| Tab 1 | "Button A" in the right panel (background) | "Delete" in the modal |
| Tab 2 | "Button B" in the right panel (background) | Outside the page (browser UI) |
| Tab 3 | "Open good modal" (background) | "Cancel" in the modal |
| Tab 4 to 6 | "Cancel" in the modal, then "Delete" in the modal, then the footer link "codeslog.com" (background) | Alternates between the buttons in the modal and the browser UI (never reaches the background) |
| ESC | Stays open | Closes, and focus returns to the opener |
| After closing with "Cancel" | Focus falls to `body` | The opener (the demo's restoration code puts it back) |
{caption="Results of operating both modals by keyboard in Chrome 155 (2026-10-11)"}

In the `div` modal, **Tab never got into the modal** across three presses. A dialog sits in the middle of the screen, and the keyboard is circling behind it. Because the modal `div` comes after the main content (`<main>`) in the HTML, Tab only reaches the modal after passing every button in the main content. It gets in on the fourth press and slips back out to the footer on the sixth. For someone using a mouse, this accident is invisible.

The `<dialog>` never touched a background button. The first loop was the same in all five runs: "Delete, outside the page, Cancel." From the second loop on, it sometimes went through the outside of the page and sometimes went straight back to "Cancel" from one measurement to the next. Automation tools couldn't reproduce "outside the page" reliably, so I grouped Tab 4 to 6 into a single row.

When ESC closed the dialog and focus returned to the opener, that was the browser's doing, not the demo's restoration code. The demo only returns focus to the opener when you press the "Cancel" button, and ESC doesn't go through that code. Since I opened it with the keyboard, the "focus at the moment of opening" was the opener, and the browser sent focus back there. If I had opened it with the mouse in Safari, the result would have been different.

This table is from one browser, Chrome. I didn't measure Safari or Firefox, and how screen readers actually read these isn't in this table either. The surest way is to press through the demo in your own browser.

## Animation and prefers-reduced-motion

If you animate the modal when it opens, take care of `prefers-reduced-motion` as well. Users who turn on reduced motion may not want animation, or find it hard to bear because of dizziness.

```css
/* Open animation. For a div modal, replace both dialog[open]
   below with #custom-modal:not([hidden]) */
dialog[open] {
  animation: slide-up 0.2s ease-out;
}

@keyframes slide-up {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* When reduced motion is on */
@media (prefers-reduced-motion: reduce) {
  dialog[open] {
    animation: none;
  }
}
```

## Modal accessibility checklist

Once you've built a modal, check these one by one.

1. A `<dialog>` is opened with `showModal()`, or it has `role="dialog"` (`alertdialog` for a warning-style confirmation) and `aria-modal="true"`
2. The title is linked with `aria-labelledby`
3. When it opens, focus moves into the modal, and for an action that can't be undone it goes to "Cancel" first
4. The background is actually blocked: `<dialog>.showModal()` or `inert` (`aria-modal` alone doesn't pass)
5. Tab doesn't leak into the background. A `div` modal cycles inside the modal
6. There's a visible close (Cancel) button, and ESC closes it too
7. On close, focus returns to the element that opened it, and if that element is gone, it goes somewhere nearby
8. The background doesn't scroll, and the scroll lock is released even when closing with ESC
9. Long content scrolls inside the modal
10. If there's animation, it's turned off under `prefers-reduced-motion`

## Wrapping up

If you can, use `<dialog>` and `showModal()`. The browser handles blocking the background, ESC, and focus movement and restoration, leaving mostly scroll locking and remembering the opener. If you have to build it yourself, blocking the background with `inert` comes first, and the focus trap is a loop layered on top.

The four accidents from the intro (Tab leaking into the background, ESC doing nothing, a screen reader reading content with no announcement, and focus vanishing) are now a few lines in a checklist. And the trap is finally set in the right place: inside the modal, not the background.

---

{{< faq >}}

## More in this series

- [Focus Management in SPAs: Keeping Screen Readers on Track After Route Changes]({{< relref "/posts/spa-focus-management" >}}): the same root as modal focus restoration
- [Form Validation Timing: From Blur to aria-invalid]({{< relref "/posts/form-ux-validation" >}}): if you put a form inside a modal, validation timing matters too
- [Supporting High Contrast Mode Properly: forced-colors and System Colors]({{< relref "/posts/high-contrast-mode" >}}): the mode where a modal overlay disappears entirely

[Browse the full Frontend × Accessibility series](/en/series/frontend--accessibility/): a series that connects an accessibility lens to everyday frontend topics.

## References

- <a href="https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element" target="_blank" title="Opens in new window">The dialog element (HTML Living Standard)</a>
- <a href="https://github.com/whatwg/html/issues/8339" target="_blank" title="Opens in new window">Allow modal dialogs to trap focus, avoiding tabbing to the URL bar (WHATWG issue #8339)</a>
- <a href="https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/" target="_blank" title="Opens in new window">Dialog (Modal) Pattern (W3C ARIA Authoring Practices Guide)</a>
- <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert" target="_blank" title="Opens in new window">inert (MDN)</a>
- <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#clicking_and_focus" target="_blank" title="Opens in new window">button: Clicking and focus (MDN)</a>
- <a href="https://bugs.webkit.org/show_bug.cgi?id=229895" target="_blank" title="Opens in new window">WebKit bug 229895: Clicking button drops focus entirely</a>
- <a href="https://bugs.webkit.org/show_bug.cgi?id=236322" target="_blank" title="Opens in new window">WebKit bug 236322: Dialog's "previously focused element" and focus-visible</a>
- <a href="https://bugs.webkit.org/show_bug.cgi?id=273846" target="_blank" title="Opens in new window">WebKit bug 273846: VoiceOver does not announce dialog on showModal when it uses aria-labelledby (iOS)</a>
- <a href="https://bugs.webkit.org/show_bug.cgi?id=302351" target="_blank" title="Opens in new window">WebKit bug 302351: VoiceOver not interacting with dialog elements (macOS)</a>
- <a href="https://bugs.webkit.org/show_bug.cgi?id=317941" target="_blank" title="Opens in new window">WebKit bug 317941: body with overflow: hidden is still scrollable on iOS sometimes</a>
- <a href="https://github.com/focus-trap/focus-trap" target="_blank" title="Opens in new window">focus-trap (GitHub)</a>

