Dialog & Popover API
For years, building accessible modals, dropdowns, and tooltips meant pulling in a UI library or hand-rolling focus traps, Escape-key handlers, ARIA roles, and z-index stacking hacks. Modern browsers now ship two complementary primitives that cover most of those cases natively: the <dialog> element and the Popover API.
Both promote their content into the browser's top layer, so you no longer fight stacking contexts. Dialogs excel at modal and non-modal prompts that need a focus trap and a return value. Popovers shine for menus, tooltips, and light-dismiss overlays that open from a trigger button with almost no JavaScript.
This guide covers the full API surface: show() vs showModal(), close() and returnValue, ::backdrop, form method="dialog", autofocus and focus trapping, light dismiss, dialog events, Popover attributes (popover, popovertarget, popovertargetaction), auto vs manual modes, :popover-open, the invoker relationship, top-layer behavior, comparison tables, production patterns, browser support, and common mistakes.
info
Choosing the right primitive starts with the interaction model. A confirm delete flow that blocks the page is a modal dialog. A user-menu that dismisses when you click outside is a popover. A complex multi-step wizard with custom focus restoration, portal mounting, and animation orchestration may still justify a library — but many apps never need one.
| Concern | <dialog> | Popover API | Modal library |
|---|---|---|---|
| Modal blocking | Yes via showModal() | No (non-modal overlays) | Usually yes |
| Light dismiss | No by default | Yes (auto mode) | Configurable |
| Focus trap | Yes for showModal() | Limited (focus moves in, Escape closes) | Usually yes |
| Declarative trigger | Needs JS (or form patterns) | popovertarget — often zero JS | Component props |
| Return value | close(returnValue) | Not built-in | Callbacks / promises |
| Top layer | Yes | Yes | Portals / z-index |
| Best for | Alerts, confirms, forms | Menus, tooltips, toasts | Complex animated systems |
note
The <dialog> element is hidden by default (UA stylesheet sets display: none unless the open attribute is present). You open it with one of two methods:
| Method | Modal? | Backdrop | Focus trap | Inert page |
|---|---|---|---|---|
| show() | No | None | No | Page stays interactive |
| showModal() | Yes | ::backdrop | Yes | Rest of page is inert |
Use showModal() for confirmations, destructive actions, and any flow that must capture attention. Use show() for non-blocking panels — for example a persistent inspector that should not freeze the rest of the UI. Prefer popovers for menus and light-dismiss overlays instead of non-modal dialogs when possible.
| 1 | <dialog id="confirmDialog"> |
| 2 | <h2>Delete project?</h2> |
| 3 | <p>This action cannot be undone.</p> |
| 4 | <form method="dialog"> |
| 5 | <button value="cancel">Cancel</button> |
| 6 | <button value="confirm">Delete</button> |
| 7 | </form> |
| 8 | </dialog> |
| 9 | |
| 10 | <button id="openConfirm">Delete project</button> |
| 11 | |
| 12 | <script> |
| 13 | const dialog = document.getElementById("confirmDialog"); |
| 14 | document.getElementById("openConfirm").addEventListener("click", () => { |
| 15 | dialog.showModal(); // modal + backdrop + focus trap |
| 16 | }); |
| 17 | </script> |
warning
Call dialog.close() or dialog.close(returnValue) to dismiss the dialog. The optional string becomes dialog.returnValue, which you can read in the close event handler. With <form method="dialog">, submitting a button automatically closes the dialog and sets returnValue from the button's value.
| 1 | const dialog = document.getElementById("confirmDialog"); |
| 2 | |
| 3 | dialog.addEventListener("close", () => { |
| 4 | if (dialog.returnValue === "confirm") { |
| 5 | deleteProject(); |
| 6 | } |
| 7 | // returnValue is "" if closed via Escape or close() with no arg |
| 8 | }); |
| 9 | |
| 10 | // Imperative close with a value |
| 11 | dialog.close("confirm"); |
best practice
Modal dialogs render a ::backdrop pseudo-element behind the dialog in the top layer. Style it for dimming, blur, or branded overlays. Non-modal show() dialogs do not get a backdrop. Popovers can also style ::backdrop when they are in the top layer (useful for full-screen drawers).
| 1 | dialog::backdrop { |
| 2 | background: rgba(0, 0, 0, 0.55); |
| 3 | backdrop-filter: blur(2px); |
| 4 | } |
| 5 | |
| 6 | dialog { |
| 7 | border: 1px solid #222; |
| 8 | border-radius: 8px; |
| 9 | padding: 1.5rem; |
| 10 | background: #0d0d0d; |
| 11 | color: #e0e0e0; |
| 12 | max-width: 28rem; |
| 13 | } |
| 14 | |
| 15 | /* Animate backdrop when supported */ |
| 16 | @starting-style { |
| 17 | dialog::backdrop { |
| 18 | opacity: 0; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | dialog::backdrop { |
| 23 | opacity: 1; |
| 24 | transition: opacity 200ms ease; |
| 25 | } |
A form with method="dialog" inside a <dialog> does not navigate. On submit, the browser closes the dialog and sets returnValue from the activated submitter. This is the idiomatic pattern for confirm/cancel UIs and small modal forms.
| 1 | <dialog id="renameDialog"> |
| 2 | <form method="dialog"> |
| 3 | <label for="name">New name</label> |
| 4 | <input id="name" name="name" required /> |
| 5 | <menu> |
| 6 | <button value="cancel">Cancel</button> |
| 7 | <button value="save">Save</button> |
| 8 | </menu> |
| 9 | </form> |
| 10 | </dialog> |
pro tip
When a modal dialog opens, the browser moves focus into the dialog. If an element has autofocus, that element receives focus; otherwise the dialog itself (or the first focusable control) does. Tab / Shift+Tab cycle within the dialog while it is modal. Closing restores focus to the previously focused element (usually the opener button).
| 1 | <dialog id="loginDialog"> |
| 2 | <form method="dialog"> |
| 3 | <label for="email">Email</label> |
| 4 | <input id="email" type="email" name="email" autofocus required /> |
| 5 | |
| 6 | <label for="password">Password</label> |
| 7 | <input id="password" type="password" name="password" required /> |
| 8 | |
| 9 | <button value="cancel" formnovalidate>Cancel</button> |
| 10 | <button value="login">Log in</button> |
| 11 | </form> |
| 12 | </dialog> |
warning
Light dismiss means clicking outside (or pressing Escape) closes the overlay. Auto popovers do this by default. Modal dialogs close on Escape (firing cancel then close) but do not close when clicking the backdrop — you must add that behavior if product requirements demand it.
| 1 | const dialog = document.getElementById("confirmDialog"); |
| 2 | |
| 3 | // Optional: close modal when clicking the backdrop |
| 4 | dialog.addEventListener("click", (event) => { |
| 5 | const rect = dialog.getBoundingClientRect(); |
| 6 | const inside = |
| 7 | event.clientX >= rect.left && |
| 8 | event.clientX <= rect.right && |
| 9 | event.clientY >= rect.top && |
| 10 | event.clientY <= rect.bottom; |
| 11 | if (!inside) dialog.close("cancel"); |
| 12 | }); |
note
Two events matter most. cancel fires when the user presses Escape (or otherwise requests cancel). You can call event.preventDefault() on cancel to keep the dialog open (for example, to show an unsaved-changes warning). close fires after the dialog has closed — read returnValue here.
| 1 | dialog.addEventListener("cancel", (event) => { |
| 2 | if (formIsDirty) { |
| 3 | event.preventDefault(); // keep dialog open |
| 4 | showUnsavedWarning(); |
| 5 | } |
| 6 | }); |
| 7 | |
| 8 | dialog.addEventListener("close", () => { |
| 9 | console.log("Closed with:", dialog.returnValue); |
| 10 | }); |
The <dialog> element maps to role="dialog" automatically. Modal dialogs also make the rest of the document inert, which is critical for screen reader users. Label the dialog with aria-labelledby (pointing at the heading) and optionally aria-describedby for helper text.
| 1 | <dialog |
| 2 | id="deleteDialog" |
| 3 | aria-labelledby="delete-title" |
| 4 | aria-describedby="delete-desc" |
| 5 | > |
| 6 | <h2 id="delete-title">Delete account?</h2> |
| 7 | <p id="delete-desc"> |
| 8 | Your data will be permanently removed within 30 days. |
| 9 | </p> |
| 10 | <form method="dialog"> |
| 11 | <button value="cancel">Keep account</button> |
| 12 | <button value="delete">Delete</button> |
| 13 | </form> |
| 14 | </dialog> |
best practice
User-agent styles give dialogs a border, centering via margin: auto, and default padding. Reset and brand them carefully. The [open] attribute is present when the dialog is showing; combine with :modal to target only modal instances.
| 1 | dialog { |
| 2 | width: min(100% - 2rem, 28rem); |
| 3 | border: 1px solid #222; |
| 4 | border-radius: 12px; |
| 5 | padding: 0; |
| 6 | background: #111; |
| 7 | color: #e0e0e0; |
| 8 | box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45); |
| 9 | } |
| 10 | |
| 11 | dialog:modal { |
| 12 | /* styles unique to showModal() */ |
| 13 | } |
| 14 | |
| 15 | dialog[open] { |
| 16 | display: flex; |
| 17 | flex-direction: column; |
| 18 | } |
| 19 | |
| 20 | dialog::backdrop { |
| 21 | background: rgb(0 0 0 / 0.6); |
| 22 | } |
The Popover API is largely declarative. Mark any element with the popover attribute, then wire a button with popovertarget (and optionally popovertargetaction).
| Attribute | Where | Purpose |
|---|---|---|
| popover | On the overlay element | Makes it a popover; values: empty/auto or manual |
| popovertarget | On a button / input | ID of the popover to control |
| popovertargetaction | On the invoker | toggle (default), show, or hide |
| popovertargetaction="show" | Invoker | Only opens; never toggles closed |
| popovertargetaction="hide" | Invoker (often inside popover) | Explicit close control |
| 1 | <button popovertarget="menu" popovertargetaction="toggle"> |
| 2 | Account |
| 3 | </button> |
| 4 | |
| 5 | <div id="menu" popover> |
| 6 | <button popovertarget="menu" popovertargetaction="hide">Close</button> |
| 7 | <a href="/profile">Profile</a> |
| 8 | <a href="/settings">Settings</a> |
| 9 | <a href="/logout">Log out</a> |
| 10 | </div> |
popover or popover="auto" creates an auto popover: light dismiss, Escape closes it, and opening one auto popover closes other auto popovers.popover="manual" requires explicit show/hide (via API or invokers) and does not light-dismiss — ideal for toasts and sticky helpers.
| Mode | Light dismiss | Closes other autos | Typical use |
|---|---|---|---|
| auto | Yes | Yes | Menus, selects, tooltips |
| manual | No | No | Toasts, teaching tips, multi-open panels |
| 1 | const toast = document.getElementById("toast"); |
| 2 | // manual popover — show / hide yourself |
| 3 | toast.showPopover(); |
| 4 | setTimeout(() => toast.hidePopover(), 3000); |
| 5 | |
| 6 | // Toggle API |
| 7 | toast.togglePopover(); |
Style open popovers with the :popover-open pseudo-class. Pair with transitions and @starting-style for entry animations. You can also style the invoker when its popover is open using :popover-open interest / anchor-related selectors as browser support grows — today, toggle a class from events if you need invoker styling everywhere.
| 1 | [popover] { |
| 2 | border: 1px solid #222; |
| 3 | border-radius: 8px; |
| 4 | padding: 0.75rem; |
| 5 | background: #111; |
| 6 | color: #e0e0e0; |
| 7 | opacity: 0; |
| 8 | transform: translateY(-4px); |
| 9 | transition: opacity 150ms ease, transform 150ms ease; |
| 10 | } |
| 11 | |
| 12 | [popover]:popover-open { |
| 13 | opacity: 1; |
| 14 | transform: translateY(0); |
| 15 | } |
| 16 | |
| 17 | @starting-style { |
| 18 | [popover]:popover-open { |
| 19 | opacity: 0; |
| 20 | transform: translateY(-4px); |
| 21 | } |
| 22 | } |
The button with popovertarget is the invoker. The browser associates it with the popover for accessibility and focus return. In newer browsers, popover toggle events expose event.newState and related invoker information. Imperative opens via showPopover() still work when there is no invoker (manual toasts).
| 1 | const menu = document.getElementById("menu"); |
| 2 | |
| 3 | menu.addEventListener("beforetoggle", (event) => { |
| 4 | // event.newState === "open" | "closed" |
| 5 | console.log("Popover will be", event.newState); |
| 6 | }); |
| 7 | |
| 8 | menu.addEventListener("toggle", (event) => { |
| 9 | console.log("Popover is now", event.newState); |
| 10 | }); |
Dialogs opened with showModal() and open popovers are promoted to the browser top layer — above all document z-index stacking. That means a z-index: 9999 sidebar cannot cover a modal dialog. Nested top-layer elements stack in the order they were opened. Closing removes them from the top layer.
info
| Feature | <dialog> showModal() | <dialog> show() | popover=auto | popover=manual |
|---|---|---|---|---|
| Top layer | Yes | No* | Yes | Yes |
| Backdrop | Yes | No | Optional | Optional |
| Escape closes | Yes | No | Yes | No |
| Light dismiss | No | No | Yes | No |
| Focus trap | Yes | No | Partial | Partial |
| returnValue | Yes | Yes | No | No |
| Zero-JS trigger | No | No | Yes | Yes (invoker) |
*Non-modal dialogs opened with show() are not placed in the top layer the same way modal dialogs are; prefer popovers for non-modal overlays that must escape stacking contexts.
Confirm dialog
Modal dialog + method="dialog" + returnValue branch. Keep Cancel as the safer default focus target.
| 1 | <dialog id="confirm"> |
| 2 | <form method="dialog"> |
| 3 | <p>Remove this item from your cart?</p> |
| 4 | <button value="cancel" autofocus>Cancel</button> |
| 5 | <button value="ok">Remove</button> |
| 6 | </form> |
| 7 | </dialog> |
Dropdown menu
Auto popover with popovertarget. Position with CSS anchor positioning where supported, or absolute positioning near the trigger.
| 1 | <button popovertarget="user-menu">Menu</button> |
| 2 | <div id="user-menu" popover> |
| 3 | <a href="/account">Account</a> |
| 4 | <a href="/billing">Billing</a> |
| 5 | <hr /> |
| 6 | <a href="/logout">Log out</a> |
| 7 | </div> |
Tooltip-like popover
For rich tooltips (not just title), use a small auto popover. For pure hover tooltips without click, you may still need JS or interest invokers as they land; click-to-toggle is the progressive-enhancement baseline.
| 1 | <button popovertarget="hint" aria-describedby="hint">?</button> |
| 2 | <div id="hint" popover role="tooltip"> |
| 3 | API keys are shown once. Store them securely. |
| 4 | </div> |
Toast with manual popover
| 1 | <div id="toast" popover="manual" role="status"> |
| 2 | Saved successfully |
| 3 | </div> |
| 4 | |
| 5 | <script> |
| 6 | function notify(message) { |
| 7 | const toast = document.getElementById("toast"); |
| 8 | toast.textContent = message; |
| 9 | toast.showPopover(); |
| 10 | clearTimeout(toast._t); |
| 11 | toast._t = setTimeout(() => toast.hidePopover(), 2500); |
| 12 | } |
| 13 | </script> |
<dialog> is Baseline widely available in modern evergreen browsers. The Popover API reached broad support across Chrome, Edge, Safari, and Firefox. Feature-detect before using progressive enhancements:
| 1 | if (typeof HTMLDialogElement === "function") { |
| 2 | // dialog supported |
| 3 | } |
| 4 | |
| 5 | if (HTMLElement.prototype.hasOwnProperty("popover") || "popover" in document.createElement("div")) { |
| 6 | // popover attribute supported |
| 7 | } |
| 8 | |
| 9 | // Or CSS: |
| 10 | // @supports selector(:popover-open) { ... } |
note
Match the primitive to the job
Modal decisions → showModal(). Menus / light dismiss → auto popover. Toasts → manual popover.
Label every dialog
Use aria-labelledby / aria-describedby. Visible headings are not optional for accessibility.
Prefer method=dialog
Confirm UIs should use form submission semantics and returnValue instead of ad-hoc click handlers.
One close path for side effects
Perform mutations in the close listener after inspecting returnValue.
Respect Escape
Only preventDefault() on cancel when you must protect unsaved work — and offer a clear path forward.
| Mistake | Why it hurts | Fix |
|---|---|---|
| Using open attribute for modals | No backdrop / wrong mode | Call showModal() |
| Building menus with showModal() | Over-blocking; poor UX | Use auto popover |
| Autofocus on Delete | Accidental activation | Focus Cancel or a field |
| z-index wars for overlays | Fragile stacking | Rely on top layer |
| Manual role=dialog on <dialog> | Redundant / conflicting | Use native semantics + labels |
| Auto popover for toasts | Light dismiss fights the toast | popover="manual" |
note
note
note
note
info
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.