All articles
Design & UXJune 24, 2026 6 min read

Modal Dialogs Are Where Accessibility Goes to Die

Modals look simple in Figma and break in twelve different ways in production. Here's the focus trap, scroll lock, and ESC handling we actually ship — and why the native <dialog> element finally earns its keep in 2026.

Modals are the most-shipped, least-tested component in almost every design system we audit. The Figma file shows a tidy card with a backdrop. Production ships a div soup that traps screen reader users, scrolls the body underneath, and swallows the Escape key when a date picker is open inside it.

This is a war-story write-up of what actually breaks, what the spec now gives you for free, and the small list of things you still have to do yourself in 2026.

Why modals are uniquely hostile to accessibility

A modal is the one component that deliberately violates the user's expected flow. It steals focus, hides the rest of the page from assistive tech, and demands a decision. Get any single piece wrong and the user is stuck — sometimes literally, with no visible way out.

The failure modes we see most often during audits:

  • Focus stays on the button that opened the modal, so keyboard users tab through the page behind it.
  • The backdrop is a div with an onClick, so it's invisible to screen readers and unreachable by keyboard.
  • The body scrolls when the user scrolls inside the modal (iOS Safari's favourite party trick).
  • Escape closes the modal even when a nested combobox or select is open, dismissing both.
  • Focus returns to document.body after close, dumping the user at the top of the page.
  • The modal is announced as "dialog" with no accessible name, because nobody wired up aria-labelledby.

Each of these is a five-minute fix in isolation. Together they're why your support inbox has a ticket titled "can't close the popup on my iPad".

The native <dialog> element earns its keep

For years the advice was "don't use <dialog>, polyfill it". As of 2024 every evergreen browser supports it, including the showModal() method, the top layer, and the ::backdrop pseudo-element. In 2026 there is no good reason to hand-roll a modal from divs unless you have a very specific design constraint.

What <dialog> gives you for free:

  • Renders in the top layer, above every z-index and overflow: hidden ancestor. No more "the modal is behind the sticky header" bug.
  • Traps focus inside the dialog automatically.
  • Closes on Escape and dispatches a close event.
  • Inert background — clicks and tab focus can't escape.
  • A ::backdrop pseudo-element you can style without an extra div.

What it still does not give you:

  • Scroll lock on the body.
  • A click-outside-to-close handler.
  • Focus restoration to a specific element after close.
  • Animation in/out (you need @starting-style or a small JS bridge).

Here is the smallest version we actually ship:

<dialog id="confirm-delete" aria-labelledby="confirm-delete-title">
  <form method="dialog">
    <h2 id="confirm-delete-title">Delete this project?</h2>
    <p>This removes all environments and cannot be undone.</p>
    <menu>
      <button value="cancel">Cancel</button>
      <button value="confirm" autofocus>Delete</button>
    </menu>
  </form>
</dialog>
const dlg = document.getElementById('confirm-delete');
const opener = document.getElementById('open-delete');

opener.addEventListener('click', () => dlg.showModal());

dlg.addEventListener('close', () => {
  if (dlg.returnValue === 'confirm') performDelete();
  opener.focus(); // explicit restoration, don't trust the default
});

// Click-outside-to-close
dlg.addEventListener('click', (e) => {
  const r = dlg.getBoundingClientRect();
  const outside = e.clientX < r.left || e.clientX > r.right ||
                  e.clientY < r.top  || e.clientY > r.bottom;
  if (outside) dlg.close('cancel');
});

That's roughly fifteen lines of JS for behaviour that used to need a 4KB library. The method="dialog" form is the underused trick — submit buttons close the dialog and expose their value via returnValue. No state library required.

When to skip <dialog> anyway

There are real cases for a custom implementation:

  • You need a non-modal dialog (a side panel that doesn't inert the page). Use dialog.show() instead of showModal(), or a custom component with role="dialog".
  • You need the modal to animate from a triggering element (shared element transitions). The top layer makes FLIP animations awkward; consider the View Transitions API instead.
  • You're inside a Shadow DOM boundary where <dialog> focus behaviour gets weird with slotted content.

For 90% of confirm-and-form modals, native wins.

Scroll lock without breaking iOS

The canonical bug: you set body { overflow: hidden } when the modal opens, and on iOS Safari the body still scrolls behind the dialog when the user drags inside it. The fix that has held up for us:

function lockScroll() {
  const y = window.scrollY;
  document.body.style.position = 'fixed';
  document.body.style.top = `-${y}px`;
  document.body.style.width = '100%';
  document.body.dataset.scrollY = String(y);
}

function unlockScroll() {
  const y = Number(document.body.dataset.scrollY || 0);
  document.body.style.position = '';
  document.body.style.top = '';
  document.body.style.width = '';
  window.scrollTo(0, y);
}

Yes, it's ugly. Yes, overscroll-behavior: contain on the dialog helps too. But the position-fixed dance is what survives contact with real iPhones in our QA. Don't forget to restore scroll on close, including when the modal closes via Escape or backdrop click.

Focus, restoration, and the inert attribute

showModal() handles the focus trap, but two things bite you:

Initial focus. Without autofocus, focus lands on the first focusable element, which is often the close button. For destructive dialogs that's usually fine. For forms, you want focus on the first input. For confirmations, on the safer choice (Cancel, not Delete). Use autofocus on the element you actually want focused, and verify with VoiceOver — Safari has been inconsistent here in the past.

Restoration on close. The spec says focus returns to the previously focused element. In practice, if that element was inside something that got re-rendered (React, Vue, anything), focus lands on body. Always store the trigger ref and call .focus() on it explicitly in your close handler. Two lines, saves a screen reader user from scrolling back to the top of a 4000px page.

For non-modal cases where you can't use <dialog>, the inert attribute is now your best friend. Apply it to everything that isn't the dialog and you get the same background-inertness behaviour:

const root = document.getElementById('app-root');
root.inert = true;       // background is now untabbable and hidden from AT
// ...
root.inert = false;      // restore

Nested interactions and the Escape key problem

The subtle one. User opens a modal. Inside it, they open a combobox or a custom date picker. They press Escape expecting to close just the popover. Your modal closes too, because both listeners fired.

The fix is event ordering. The inner component should call event.stopPropagation() on its Escape handler only when it actually handled it (i.e., its popover was open). If you blanket-stop propagation, you break the case where the user wanted to dismiss the whole dialog.

combobox.addEventListener('keydown', (e) => {
  if (e.key !== 'Escape') return;
  if (isOpen) {
    closePopover();
    e.stopPropagation(); // don't let the dialog see this
  }
  // if popover was already closed, let it bubble to the dialog
});

This is the kind of bug that never shows up in design review and always shows up in user testing.

Labels, descriptions, and announcement

A dialog needs an accessible name. The three ways, in order of preference:

  1. aria-labelledby pointing to the visible heading inside the dialog.
  2. aria-label with a string, if there's no visible title (rare and usually a design smell).
  3. The <dialog> element's own heuristics — don't rely on these, be explicit.

For longer descriptions or warnings, use aria-describedby on the dialog pointing to the body text. Screen readers will announce the title, then the description, then place focus. If you're showing a destructive confirmation, that description is what stops a user from making a mistake — write it like it matters.

Where we'd start

If you're auditing an existing modal today, open it with a keyboard, close your eyes, and try to complete the task with VoiceOver or NVDA. You'll find three bugs in the first minute. Fix those before you touch anything else.

If you're building a new one in 2026, start with <dialog> and showModal(), add explicit scroll lock and focus restoration, wire aria-labelledby to your heading, and write the click-outside handler with the bounding-rect trick above. That covers the boring 80%. The remaining 20% — animation, nested popovers, shared element transitions — is where the design system team earns their salary, and where we usually end up when clients ask us to rebuild their component library.

#accessibility#ux#frontend#design-systems

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project