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
divwith anonClick, 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.bodyafter 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-indexandoverflow: hiddenancestor. No more "the modal is behind the sticky header" bug. - Traps focus inside the dialog automatically.
- Closes on Escape and dispatches a
closeevent. - Inert background — clicks and tab focus can't escape.
- A
::backdroppseudo-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-styleor 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 ofshowModal(), or a custom component withrole="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:
aria-labelledbypointing to the visible heading inside the dialog.aria-labelwith a string, if there's no visible title (rare and usually a design smell).- 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.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Design Tokens That Survive Contact With Engineering
Most design token systems look great in Figma and fall apart in the codebase. Here's how to structure tokens so they actually hold up across Tailwind, iOS, and three product teams.

Empty States Are Your Best Onboarding Surface — Stop Wasting Them
Most empty states show a sad cloud and a 'No data yet' label. That's a dead pixel. Here's how to turn the zero state into the most persuasive screen in your product.

Skeleton Screens vs Spinners: When Each One Actually Wins
Skeleton screens aren't automatically better than spinners. Here's the decision tree we use on real projects, with code, timing thresholds, and the accessibility traps nobody talks about.
