Modal & Dialog Patterns
A modal interrupts the normal page flow to demand focused attention — a confirmation, a form, an image preview. Getting one right is mostly an accessibility problem: focus has to move into the modal, stay trapped there while it's open, and return to where it was when it closes. The native <dialog> element now handles most of that for free, which is why it's the recommended starting point over hand-rolled div-based modals.
The native <dialog> element
<dialog> is a built-in modal container. Opened with its .showModal() method, it automatically traps keyboard focus inside itself, blocks interaction with the rest of the page, closes on the Escape key, and exposes a special ::backdrop pseudo-element (see ::backdrop) you can style directly — all without a line of your own focus-management JavaScript.
<dialog id="confirm">
<form method="dialog">
<h2>Delete this item?</h2>
<p>This cannot be undone.</p>
<menu>
<button value="cancel">Cancel</button>
<button value="confirm">Delete</button>
</menu>
</form>
</dialog>
<button id="open">Delete…</button>
<script>
const dialog = document.getElementById('confirm');
document.getElementById('open').addEventListener('click', () => {
dialog.showModal();
});
dialog.addEventListener('close', () => {
console.log('Result:', dialog.returnValue);
});
</script>showModal()opens it as a top-layer, focus-trapped modal; plainshow()opens it as a non-modal panel (no focus trap, no backdrop, page stays interactive).<form method="dialog">submits by simply closing the dialog and settingreturnValueto the clicked button'svalue— no JavaScript required for the button wiring.Escape closes the dialog automatically and fires a
cancelevent, then acloseevent — listen forcloseto read the result.The dialog is rendered in the top layer, a browser-managed layer above everything else in the document, which is why
z-indexnever needs to fight it.
Styling the dialog and its backdrop
dialog {
border: none;
border-radius: 12px;
padding: 1.5rem;
width: min(90vw, 32rem);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
/* Only paints while the dialog is open */
dialog::backdrop {
background: rgb(15 23 42 / 0.55);
backdrop-filter: blur(2px);
}::backdrop only exists for elements shown via showModal() (or fullscreen elements) — it never appears for a plain show() call, which is a useful mental hook for remembering which mode you are in.Centering and open/close animation
A <dialog> is auto-centered by the UA stylesheet via inset auto-margins once it's in the top layer, so you rarely need centering CSS at all. Animating it in and out is the trickier part: an element removed from the top layer stops rendering immediately, mid-transition, unless you use @starting-style (see @starting-style) together with transition-behavior: allow-discrete.
dialog {
opacity: 0;
transform: scale(0.92) translateY(12px);
transition:
opacity 0.2s ease,
transform 0.2s ease,
overlay 0.2s ease allow-discrete,
display 0.2s ease allow-discrete;
}
dialog[open] {
opacity: 1;
transform: none;
}
/* Entry state — the values to transition FROM when [open] is added */
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.92) translateY(12px);
}
}
dialog::backdrop {
background: rgb(15 23 42 / 0);
transition: background 0.2s ease allow-discrete;
}
dialog[open]::backdrop {
background: rgb(15 23 42 / 0.55);
}transition-behavior: allow-discretelets discrete properties (display,overlay) participate in a transition instead of flipping instantly.@starting-stylesupplies the "before" values for the very first frame after the element enters the top layer — without it the browser has nothing to transition from and the fade-in never plays.The same recipe closes gracefully: call
dialog.close(), and because[open]is removed, the styles transition back before the element actually leaves the top layer.
Focus trapping and scroll locking
Focus trapping — Tab cycling only through elements inside the modal — is handled automatically by showModal() in every modern browser. What is not automatic is locking background scroll on some mobile browsers, and returning focus to the triggering element after close.
/* Prevent the page behind the modal from scrolling.
Toggle a class on <html> or <body> when a modal opens. */
html:has(dialog[open]) {
overflow: hidden;
}<script>
let lastFocused;
function openModal(dialog) {
lastFocused = document.activeElement;
dialog.showModal();
}
document.querySelectorAll('dialog').forEach((dialog) => {
dialog.addEventListener('close', () => {
lastFocused?.focus();
});
});
</script>html:has(dialog[open]) selector needs no JavaScript class toggling at all in browsers that support :has() — the scroll lock turns on and off automatically as the dialog's open attribute is added and removed. See the :has() page for more.Accessibility checklist
Give the dialog an accessible name:
aria-labelledbypointing at its heading, oraria-labelif there is no visible heading.Use a real
<h2>/<h3>for the title so screen readers announce it as a heading, not just text.Never rely on
<div role="dialog">unless you truly cannot use<dialog>— you would be re-implementing focus trap, Escape handling, and top-layer stacking by hand.Make sure the first focusable element (or the dialog itself via
autofocus) receives focus when it opens —showModal()does this automatically for the first focusable descendant.Confirm Escape closes the dialog (default browser behavior) unless you intentionally prevent it for a "you have unsaved changes" style guard, in which case provide a visible cancel affordance instead.
The popover attribute — lighter-weight overlays
For overlays that are not modal — dropdown menus, non-blocking notifications, disclosure panels — the popover attribute gives similar top-layer, light-dismiss behavior without demanding full modal focus trapping.
<button popovertarget="menu">Options</button>
<div id="menu" popover>
<ul>
<li><button>Rename</button></li>
<li><button>Duplicate</button></li>
<li><button>Delete</button></li>
</ul>
</div>popover(auto mode, the default) renders in the top layer and light-dismisses on outside click or Escape — perfect for menus and toast-style panels.popover="manual"disables light-dismiss when you need to control opening/closing entirely yourself.Style the open state with the
:popover-openpseudo-class, and animate entry the same way as dialogs —@starting-style+allow-discretetransitions.Popovers do not trap focus the way
showModal()does — use<dialog>when the interaction must be fully blocking (forms, destructive confirmations).
Dialog vs popover vs hand-rolled div — when to use which
Need | Use |
|---|---|
Blocks the rest of the page, traps focus, demands a decision |
|
Non-modal panel: menu, tooltip-like popup, toast |
|
Inline panel that is part of normal page flow (no overlay) | Plain |
Full custom control over stacking, animation timing, nested modals |
|
Common mistakes
Using
display: none/flextoggling on a plain<div>to fake a modal — you lose the top layer,::backdrop, focus trap, and Escape handling, and have to hand-build all of it with JavaScript and ARIA.Forgetting
method="dialog"and then writing custom JavaScript just to close the dialog on every button click — the form handles it for free.Animating
displayor the top-layer transition withoutallow-discreteand@starting-style— the dialog will simply pop in/out with no animation.Not restoring focus to the trigger element after close, which strands keyboard and screen reader users.
Related pages: ::backdrop, @starting-style, :has(), and Tooltip patterns for lighter-weight overlays.