Overview
Use the native <dialog> element for modal and non-modal dialogs. It provides the accessibility semantics, focus handling, top-layer rendering and Esc handling that custom overlays have to rebuild by hand.
Choose the overlay type
Pick the first rule that matches. If unsure, use Rule 1.
Rule 1: The overlay interrupts the user and blocks the page (default)
Use for confirmations, forms, sign-in, destructive actions, and any "modal", "dialog", "lightbox" or "popup" request that does not say otherwise.
Use <dialog> opened as a modal, declaratively with command="show-modal" (see Declarative Dialog and Popover Control) or from script with showModal().
<button type="button" command="show-modal" commandfor="confirm">Delete</button>
<!-- MANDATORY: no role="dialog" or aria-modal; the native modal already provides them -->
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete this item?</h2>
<form method="dialog">
<button value="cancel" autofocus>Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
The browser provides: inert page, focus containment, top layer, ::backdrop, Esc and back-gesture dismissal, and focus return to the invoker.
- DON'T add
role="dialog",aria-modal, a focus trap,inerttoggling or anEschandler. - DON'T open it with
show()or theopenattribute. - DO stop the page scrolling behind it with the CSS below. See Prevent the Page Underneath a Modal Dialog from Scrolling for the details.
- DON'T lock scrolling with
overflow: hiddenorposition: fixedonhtmlorbody.
/* MANDATORY (both rules): without them, scrolling over the backdrop or inside the dialog scrolls the page behind it */
dialog:modal {
overscroll-behavior: contain; /* stops scroll gestures in the dialog chaining to the page */
}
dialog::backdrop {
overflow: hidden; /* ::backdrop is not a scroll container by default, so it would ignore overscroll-behavior */
overscroll-behavior: contain;
}
Not for menus or tooltips: see Rule 3.
Rule 2: The user keeps working in the page while the panel is open
Use for find bars, inspectors, palettes and compose panes that are still dialogs semantically.
Use <dialog popover> for a top-layer panel with popover dismissal (auto light-dismisses, manual does not). Use dialog.show() only when the panel must not be in the top layer.
<button type="button" popovertarget="find">Find</button>
<dialog id="find" popover="manual" aria-labelledby="find-title">
<h2 id="find-title">Find</h2>
<input type="search" aria-label="Search">
<button type="button" popovertarget="find" popovertargetaction="hide">Close</button>
</dialog>
The page stays interactive and is not made inert.
- DO include a visible close button.
- DON'T use
showModal(), which blocks the page.
Not for blocking confirmations: see Rule 1.
Rule 3: The overlay is not a dialog
Use for menus, dropdowns, tooltips, hovercards and toasts.
Use a semantic element with the popover attribute, not <dialog>.
<button type="button" popovertarget="menu">Actions</button>
<ul id="menu" popover>
<li><a href="/rename">Rename</a></li>
</ul>
Choose how it can be dismissed
The default depends on how the dialog is opened. If closedby is absent or invalid, showModal() behaves as closerequest and any other way of opening behaves as none.
- Esc and back gesture only (modal default): set nothing, or
closedby="closerequest". - Also click outside to close: use
closedby="any". - Only the developer closes it (for example a form with unsaved changes): use
closedby="none"and add a confirmation path. See Closing below.
Guidelines
Accessible name and initial focus
- DO name every dialog with
aria-labelledbypointing at its heading, oraria-labelwhen there is no visible title. - DO put
autofocuson the primary control or first field. - DO put
autofocuson the safe action (such as Cancel or Keep) in a destructive confirmation. - DON'T write code to return focus on close. The browser returns it to the invoker.
Opening
- DO open declaratively with
command="show-modal"andcommandforon a<button>. See Declarative Dialog and Popover Control. - DO use
dialog.showModal()when opening from script.
Closing
- DO close with
<form method="dialog">buttons orcommand="close". - DO choose
closedbydeliberately:any(light dismiss andEsc),closerequest(Esconly, the modal default) ornone(developer-controlled close only). - DO use
command="request-close"orrequestClose()for Cancel buttons that must honor unsaved-changes logic. They firecancelfirst and ignoreclosedby. - DO treat
closedbyas progressive enhancement.Escand the mobile back gesture already close modals without it.
Hooking into close
- DO read
dialog.returnValuein acloselistener to learn which button was used, then reset it withdialog.returnValue = ''.Escand other close requests set no value, and browsers differ on whether they clear the previous one, so without the reset a stale value can leak. Treat''as "dismissed without choosing". - DO use
cancelto interceptEscandrequest-close, but do not rely onpreventDefault()for repeatedEscpresses without user activation. - DO use
closedby="none"with an explicit confirmation step for dirty forms. - DO tell the user why the dialog stayed open when
cancelis prevented: show a visible message and reference it witharia-describedbyfrom the control that receives focus (such as Discard), so a screen reader reads it when focus moves. Without it, focus moving to a new button announces only that button's name. - DON'T let the confirmation move controls under the pointer. Reserve the message's space (hide it with
visibility, notdisplayorhidden) and add the destructive button where existing buttons do not move (for example first in a right-aligned row), so a second click at the same spot cannot discard the user's changes.
Non-modal dialogs
- DO use
<dialog popover>(autoormanual) for a top-layer panel, anddialog.show()when you do not want the top layer. - DO include a visible close button in every non-modal dialog.
Backdrop and styling
- DO style
::backdropand usedialog:modalto target modal-only styles. - DO leave animation to Animate Elements To and From Top Layer, dragging to Build a Draggable Element, and reparenting to Persistent Top Layer UI.
- DON'T set
displayondialogunconditionally. It overrides the closed state. Scope it withdialog[open].
Accessibility
- DON'T add
role="dialog",aria-modal, a manual focus trap orinerttoggling to a native modal. - DO use a native
<button>with an accessible name for close controls. - DO use one consistent heading level for dialog titles.
Fallback strategies
closedby (dialog-closedby)
Without closedby, a modal still closes with Esc and the back gesture; only click-outside is lost, so this is graceful degradation. closedby="none" is ignored the same way: Esc fires cancel and then closes the dialog. Keep the unsaved-changes guard in a cancel handler, not in closedby="none" alone, so it also protects where closedby is missing. If your Baseline target requires light dismiss, add this where closedby is missing:
if (!('closedBy' in HTMLDialogElement.prototype)) {
// Test the pointer position, not event.target: the <dialog> is also the target for its own
// padding and the gaps between its children, so a target check would close on those clicks
const outside = (e, dialog) => {
const r = dialog.getBoundingClientRect();
return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
};
for (const dialog of document.querySelectorAll('dialog[closedby="any"]')) {
let pressedOutside = false;
dialog.addEventListener('pointerdown', (e) => { pressedOutside = outside(e, dialog); });
// Require the press and the release outside, so a text-selection drag that ends on the backdrop does not dismiss
dialog.addEventListener('pointerup', (e) => { if (pressedOutside && outside(e, dialog)) dialog.close(); });
}
}
overscroll-behavior
Treat scroll containment as progressive enhancement. Do not feature-detect it: @supports reports the property as supported even where non-scrollable containers ignore it. Where it is not enforced, the dialog still contains scroll whenever its own content overflows, and showModal() still makes the page inert. Do not fall back to overflow: hidden or position: fixed on html or body, which removes the root scrollbar and shifts the layout.
Invoker Commands (command / commandfor)
If your Baseline target does not include Invoker Commands, keep the same markup and conditionally load the polyfill (the same experience, as progressive enhancement). Detect on the interface and never load it unconditionally:
// MANDATORY: feature detect on HTMLButtonElement.prototype and load only when native support is missing
if (!('commandForElement' in HTMLButtonElement.prototype)) {
import('invokers-polyfill'); // npm install invokers-polyfill
}
Only Rules 2 and 3 use Popover.
Fallbacks & browser support for Popover
The Popover API is mostly progressive enhancement, but its defining behaviors — top-layer promotion, light-dismiss, and popovertarget invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
Polyfill: To support the popover attribute in older browsers, conditionally load @oddbird/popover-polyfill. MANDATORY: Feature detect by checking for the popover property on HTMLElement.prototype, and load the polyfill only when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
Without a bundler, import from a CDN inside a <script type="module">:
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
Styling caveat: The polyfill cannot define the real :popover-open pseudo-class, so it applies a .\:popover-open class instead. MANDATORY: Combine the two with :is() or :where(), otherwise browsers that lack :popover-open discard the entire rule:
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
Alternatively, for a legacy fallback without a polyfill, use position: fixed and manually calculate coordinates via getBoundingClientRect() or rely on default positioning with inset: auto if that's acceptable for the use case.