When to use
A modal dialog interrupts the user. Reach for it when the work that follows is both focused (one decision) and must complete or cancel before they continue. Confirmations, “edit this thing”, login prompts, payment flows. If the content is browseable or non-blocking, use a non-modal disclosure (popover, expanding section) instead.
The pattern
The native <dialog> element with .showModal() does an enormous amount of work for you. It:
- moves focus to the first focusable child when opened,
- traps focus inside while open (Tab and Shift+Tab cycle within),
- closes on Escape without you wiring up a handler,
- renders a
::backdropyou can style, - makes the page behind effectively
inertso screen readers and pointer users can’t reach it, - and returns focus to the trigger when closed.
<button type="button" data-modal-open>Edit profile</button>
<dialog aria-labelledby="edit-title">
<form method="dialog">
<h2 id="edit-title">Edit profile</h2>
<button type="button" aria-label="Close" data-modal-close>×</button>
<!-- form fields here -->
<button type="button" data-modal-close>Cancel</button>
<button type="submit">Save</button>
</form>
</dialog> const dialog = document.querySelector('dialog');
document.querySelector('[data-modal-open]')
.addEventListener('click', () => dialog.showModal());
dialog.querySelectorAll('[data-modal-close]')
.forEach((btn) => btn.addEventListener('click', () => dialog.close())); The <form method="dialog"> trick makes pressing Enter in a form field, or activating a <button type="submit">, close the dialog and return its returnValue. Pair that with the explicit Cancel button (which calls dialog.close()) and you have a complete keyboard story.
Naming the dialog
Give the dialog an accessible name with aria-labelledby pointing at the visible title. If there isn’t a visible title, use aria-label. Without one, screen readers announce “dialog” with no context — the user has no idea what they’re looking at.
Initial focus
.showModal() moves focus to the first sequentially focusable element. That’s usually fine — but for destructive flows (“Delete this account?”), you want focus on Cancel, not on the destructive button. Either reorder the DOM, or call .focus() on the desired element after .showModal().
Anti-pattern: the rolled-your-own overlay
Every component library has one of these. A <div> styled to look like a dialog, layered with position: fixed and a black overlay. Nothing else.
What’s missing:
- No focus management. Opening the “dialog” leaves focus on the trigger button behind it. Tab continues to traverse the page underneath.
- No focus trap. A keyboard user can Tab right out of the dialog into the page beneath.
- Page behind isn’t inert. A screen reader user can swipe straight into the underlying content with no idea they’re “behind” something.
- Escape does nothing. You have to add a
keydownlistener yourself, and remember to remove it on close. - No accessible name.
role="dialog"with noaria-labelledbyoraria-labelis announced as just “dialog” — meaningless. - Fake controls.
<span>and<div>with click handlers — not focusable, no role, no keyboard activation.
Recreating what <dialog> gives you is a long file of focus management, keyboard listeners, ARIA wiring, and inert toggling. Or you type <dialog>.
Common variations
- Non-modal dialog — call
.show()instead of.showModal(). The dialog appears but doesn’t trap focus and the page behind stays interactive. Use for things like “save in progress” notices. - Confirmation dialogs — pass the
returnValuefrom a<button type="submit" value="confirm">and readdialog.returnValueafter close to know what the user chose. - Form-in-dialog —
<form method="dialog">lets a normal submit close the dialog. Use a real submit handler (form.addEventListener('submit', ...)) for validation; callevent.preventDefault()to keep the dialog open if validation fails. - Fullscreen / sheet on mobile — style with
inline-size: 100%andblock-size: 100dvhat small viewports. The<dialog>mechanics stay the same; only the styling changes. - Closing on backdrop click — there’s no built-in. If you want it, listen for clicks on the dialog itself and close when the click target is the dialog (i.e. user clicked the backdrop, not a child).
Checklist
- Uses
<dialog>opened with.showModal()(not.show(), not a fake overlay) - Has an accessible name via
aria-labelledbyoraria-label - Has at least one focusable control inside (close button counts)
- Close button has an accessible label (
aria-label="Close dialog"if it’s an icon) - For destructive flows, default focus lands on the cancel/safe option
- Escape closes the dialog (free with
<dialog>) - Returning focus to the trigger when closed (also free)