Modal Dialogs Are Still Broken in 2026: A Field Guide to Getting Them Right
Modals look trivial until you ship one. Focus traps leak, ESC closes the wrong thing, screen readers announce chaos. Here's what we check before a dialog gets within a mile of production.

Every design system has a <Modal> component. Almost none of them are correct. We've audited enough codebases to say that with a straight face — the modal is where accessibility, motion, and state management all collide, and it's where most teams quietly give up.
This is the checklist we actually run through before a dialog ships. Not the Wikipedia version — the one informed by the bugs we've shipped and then had to fix at 2am.
Start by asking if it should be a modal at all
Before any code, the first question: does this interaction actually need to interrupt the user? A modal is a hard stop. It says "nothing else matters until you deal with me." That's appropriate for destructive confirmations, authentication, and blocking errors. It is not appropriate for:
- Editing a record (use a side panel or inline edit)
- Showing extra details (use a popover, tooltip, or expand)
- Multi-step forms longer than two screens (use a dedicated route)
- Marketing nudges (use a banner, or better, don't)
If the content inside the modal could reasonably be a page, make it a page. Pages are linkable, back-button-friendly, and don't fight the browser.
The "can I deep-link to this?" test
If a user emails themselves the URL and reopens it tomorrow, should the modal reappear? If yes, your modal state belongs in the URL (query param or route segment). If no, it's probably ephemeral UI and local state is fine. Teams that skip this question end up with modals that disappear on refresh and users who think the app lost their work.
Use the native <dialog> element in 2026
The <dialog> element is stable, supported everywhere relevant, and solves a huge chunk of what people used to hand-roll with portals and ARIA. It gives you:
- A top-layer rendering context (no
z-indexwars) ::backdropfor the overlay- Automatic focus management on
showModal() - Built-in ESC-to-close
- Correct implicit role and modal semantics
Here's a baseline that handles the common cases:
<dialog id="confirm-delete" aria-labelledby="confirm-title">
<form method="dialog">
<h2 id="confirm-title">Delete this project?</h2>
<p>This cannot be undone. All 42 tasks will be removed.</p>
<menu>
<button value="cancel" autofocus>Cancel</button>
<button value="confirm" class="danger">Delete</button>
</menu>
</form>
</dialog>
const dialog = document.getElementById('confirm-delete');
dialog.showModal();
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
deleteProject();
}
});
The method="dialog" form is doing real work here: submitting it closes the dialog and sets returnValue to the clicked button's value. No manual state wiring. ESC still closes it. Focus returns to the trigger element automatically.
If you're in React, libraries like Radix UI and React Aria wrap <dialog> (or faithfully reproduce its behavior) with portals and controlled state. We prefer wrapping the native element when we can.
Focus management is the whole game
This is where every rolled-your-own modal falls apart. The rules:
- On open, focus moves into the dialog — usually to the first interactive element, or to a sensible default like the Cancel button on a destructive action.
- While open, Tab and Shift+Tab cycle only within the dialog.
- On close, focus returns to the element that opened the dialog.
If you use showModal(), you get 2 and 3 for free. For rule 1, use the autofocus attribute or call .focus() in your open handler.
Why "focus the dangerous button" is wrong
We see this a lot: a confirmation modal opens with the destructive action focused. Someone slams Enter to dismiss what they think is a notification and nukes their data. For any confirmation that's hard to undo, focus the safe option. Make the destructive action a deliberate click.
The invisible focus trap bug
If your modal contains an iframe (payment widgets, embedded editors), Tab can escape into the iframe and never come back. The native dialog handles this better than hand-rolled traps, but you still need to test it. We've shipped a Stripe-embedded checkout modal that trapped keyboard users in a dead zone because the iframe focus wasn't returning cleanly. Test with the actual third-party content loaded, not a placeholder.
Scroll locking without the layout shift
When a modal opens, the body shouldn't scroll. The naive fix — body { overflow: hidden } — causes a visible layout jump on desktop because the scrollbar disappears and content reflows. The fix:
html:has(dialog[open]) {
overflow: hidden;
scrollbar-gutter: stable;
}
scrollbar-gutter: stable reserves space for the scrollbar whether or not it's visible. Set it globally on html if you can — it eliminates the shift everywhere, not just when modals open. On iOS Safari, you may still need to handle the rubber-band scroll, which is where overscroll-behavior: contain on the dialog helps.
Motion that respects the user
A modal that slams onto the screen feels aggressive. One that eases in over 800ms feels sluggish. Our rule of thumb: 120 – 200ms for enter, 80 – 120ms for exit. Exits should be slightly faster than entries — users have already made the decision to leave, so get out of their way.
dialog {
opacity: 0;
transform: translateY(8px) scale(0.98);
transition: opacity 160ms ease-out, transform 160ms ease-out, overlay 160ms allow-discrete, display 160ms allow-discrete;
}
dialog[open] {
opacity: 1;
transform: translateY(0) scale(1);
}
@starting-style {
dialog[open] {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
@media (prefers-reduced-motion: reduce) {
dialog { transition: none; }
}
@starting-style and allow-discrete are what finally let us animate display: none → display: block transitions properly. Before this, animating out required JavaScript timing hacks. Now the browser handles it.
And always honor prefers-reduced-motion. For users with vestibular disorders, a scaling modal isn't delightful — it's nauseating.
Screen reader behavior people get wrong
A few things we check with VoiceOver and NVDA on every modal:
- The dialog is announced on open, with its accessible name (from
aria-labelledbyoraria-label). - Content outside the dialog is inert. The native
<dialog>handles this. If you're rolling your own, use theinertattribute on sibling elements. - Don't put
aria-liveregions inside the modal unless you need them. If you do, usepolite, notassertive. - Error messages inside the modal should be associated with their inputs via
aria-describedby, not just visually placed nearby.
The announcement timing trap
If you open a modal and immediately shift focus to an input, some screen readers announce the input but skip the dialog title. The fix is usually to focus a non-interactive heading briefly, or structure the dialog so the title is announced as part of the input's accessible context. Test this — don't assume.
Mobile is a different design, not a smaller one
A centered 480px modal on desktop should probably be a bottom sheet on mobile. The reasons are ergonomic (thumb reach) and practical (viewport height changes when the keyboard opens). If your modal contains a form, this matters even more — on iOS, a centered modal with a focused input can end up with the input hidden behind the keyboard with no way to scroll.
Some tactics:
- Below ~640px, render as a bottom sheet with a drag handle.
- Use
dvhunits (dynamic viewport height) instead ofvhso the sheet resizes with the keyboard. - Put the primary action at the bottom, within thumb range.
- Allow swipe-down to dismiss for non-destructive flows.
Where we'd start
If you're auditing an existing modal component, run this sequence in order and fix what breaks:
- Open it with keyboard only. Can you reach the trigger, open it, interact, and close without a mouse?
- Press Tab repeatedly inside it. Does focus stay trapped and cycle sensibly?
- Close it. Did focus return to the trigger?
- Open it on an iPhone with the on-screen keyboard. Can you still see what you're typing?
- Enable
prefers-reduced-motionin your OS. Does the animation respect it? - Open it with VoiceOver running. Is the title announced?
If any of those fail, you have work to do before shipping. And if you're starting a new design system from scratch, build the modal on top of the native <dialog> element. The browser team has already solved most of this — your job is to not break what they've given you.
For more on how we approach component architecture and accessibility in production work, see our design and frontend services.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Toast Notifications Are Where Good UX Goes to Die
Toasts are the default answer for every ephemeral message, and that's exactly the problem. Here's a breakdown of when toasts earn their keep, when they fail users, and what to use instead.

Form Validation Timing: When to Show Errors Without Being a Jerk
Validate too early and you shout at users mid-keystroke. Validate too late and they fill out the whole form before learning it's wrong. Here's the timing model we actually ship.

Disabled Buttons Are a UX Smell: What to Do Instead
Greyed-out buttons feel safe to designers and devs, but they punish users for your form's problems. Here's a pattern-by-pattern replacement guide that keeps buttons alive and errors honest.
