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.

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.

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
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 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), 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, 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 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.

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).

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.

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
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.

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
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.

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.

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, 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).

Results of operating both modals by keyboard in Chrome 155 (2026-10-11)
Actiondiv modal (no handling)<dialog>.showModal()
Focus right after opening by keyboardStill 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)
ESCStays openCloses, and focus returns to the opener
After closing with “Cancel”Focus falls to bodyThe opener (the demo’s restoration code puts it back)

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;
  }
}

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.


질문으로 다시 보기

Should I build modals with the dialog element?
Yes. When you open a dialog element with showModal(), the browser moves focus into the modal, closes it on ESC, draws a backdrop overlay, makes the background inert, and returns focus when it closes. Safari 15.4 and Firefox 98 joined in March 2022, so every major browser supports it. You still have to handle background scroll locking yourself, and remember the opener button for Safari.
Tab takes me out to the address bar in a dialog modal. Is that a bug?
No. A dialog opened with showModal() makes the background page inert, but it does not block browser UI such as the address bar. The discussion on the HTML spec side (WHATWG issue #8339) ended up keeping this behavior. As long as focus doesn’t leak into the background, you don’t need to add a JavaScript focus trap to a dialog.
Does a div modal need both inert and a focus trap?
Blocking the background is inert’s job. With inert alone, Tab already won’t leak into the background, and it moves between the modal and the browser UI, just like a dialog. A focus trap adds the ‘Tab only cycles inside the modal’ loop on top of that. The W3C ARIA Authoring Practices Guide (APG) modal pattern recommends that loop, so if you build a modal from a div, using both is the default.

More in this series

Browse the full Frontend × Accessibility series: a series that connects an accessibility lens to everyday frontend topics.

References