Native Dialogs & the Popover API
Every team that ships a web app eventually writes the same 200 lines: a <div> overlay, a position: fixed panel, a z-index picked by superstition, a keydown listener for Esc, a loop that traps Tab inside the panel, and a scramble to restore focus when it closes. Then a tooltip renders behind the modal, someone bumps a z-index to 999999, and the arms race begins.
The platform now hands you two primitives that make almost all of that code unnecessary: the <dialog> element and the Popover API. Both render into a special browser-managed plane called the top layer, and both come with focus management, dismissal, and stacking already wired up. This lesson is about using them well — and knowing which one to reach for.
The top layer: why the z-index wars end
Normally, where an element paints is decided by its position in the DOM and the tangle of stacking contexts around it. A child can never escape its parent’s stacking context, so a dropdown inside a overflow: hidden card gets clipped, and a tooltip inside a low-z-index sidebar can’t cover a high-z-index header no matter what number you type.
The top layer is a separate painting surface that sits above the entire document, outside every stacking context. Elements promoted to it always render on top, in the order they were added — z-index is irrelevant up there. <dialog> opened with showModal(), and any element with the popover attribute while shown, get promoted to this layer automatically.
The <dialog> element
<dialog> has been Baseline widely available since March 2022 — it works everywhere you reasonably care about, no polyfill needed. It has two modes, and the mode is chosen by how you open it, not by an attribute.
<dialog id="settings">
<h2>Settings</h2>
<p>Choose your theme.</p>
<button id="closeBtn">Done</button>
</dialog>
<button id="openBtn">Open settings</button>
const dialog = document.getElementById("settings");
document.getElementById("openBtn").onclick = () => dialog.showModal(); // modal
document.getElementById("closeBtn").onclick = () => dialog.close();
show() vs showModal()
showModal()opens a modal. The dialog goes into the top layer, a::backdropappears behind it, the rest of the page becomesinert(unclickable, unfocusable, hidden from assistive tech), focus moves inside, and Esc closes it. This is your “block everything until the user deals with this” tool.show()opens a non-modal dialog. It appears, but the page stays interactive, nothing goes inert, and Esc does not close it. It is not promoted to the top layer the way a modal is. Honestly, for most non-modal cases a popover is the better fit now — more on that below.close(returnValue?)closes either mode and fires acloseevent.
What you get for free with a modal
Compare that list — top layer, backdrop, inertness, focus trap, focus restore, Esc — against the hand-rolled version. The old way, you wrote each one, and got the focus trap subtly wrong (screen-reader virtual cursors leak past a tabindex loop; inert does not). The browser’s implementation is correct and free.
Open the modal below and try it: Tab stays inside, the page behind is dimmed and unclickable, and Esc or a click on the backdrop closes it — none of which we wrote by hand.
Styling the backdrop
The ::backdrop pseudo-element is the sheet between the dialog and the page. Style it like any element:
dialog::backdrop {
background: rgb(10 10 20 / 0.55);
backdrop-filter: blur(3px);
}
/* :modal matches only dialogs opened with showModal() */
dialog:modal {
border: none;
border-radius: 12px;
box-shadow: 0 20px 60px rgb(0 0 0 / 0.3);
}
Note that ::backdrop only exists for modal dialogs (and shown popovers) — show() and <dialog open> never generate one. Two useful state selectors: dialog:open matches any open dialog, and dialog:modal matches only the modal ones.
Return values and method="dialog"
Here’s the feature people miss. Put a <form method="dialog"> inside a dialog, and submitting that form closes the dialog without navigating or reloading — no preventDefault, no fetch. The value of the button that submitted becomes the dialog’s returnValue.
<dialog id="picker">
<form method="dialog">
<p>Pick a plan:</p>
<button value="cancel">Cancel</button>
<button value="pro">Go Pro</button>
</form>
</dialog>
const picker = document.getElementById("picker");
picker.showModal();
picker.addEventListener("close", () => {
// "cancel", "pro", or "" if closed by Esc
console.log("user chose:", picker.returnValue);
});
This turns a dialog into something that behaves almost like a synchronous prompt: open it, and the close event tells you which button won. You can also set the value manually with dialog.close("pro") from JavaScript.
See it live. Click a button and the choice arrives through returnValue; dismiss with Esc and watch returnValue stay an empty string, exactly as the caution warns.
Controlling how a dialog closes: closedby
By default a modal dialog closes on Esc but not on an outside click. The newer closedby attribute makes this explicit and adds outside-click (“light”) dismissal:
closedby="none"— only your ownclose()call closes it (good for a step you must not accidentally abandon).closedby="closerequest"— Esc and platform close gestures; this is the modal default.closedby="any"— Esc and clicking the backdrop dismisses it, like a popover.
<dialog id="quickview" closedby="any">…</dialog>
The Popover API
The Popover API reached Baseline “newly available” in January 2025. It is the right tool for the non-modal layer: menus, comboboxes, toasts, teaching callouts, “…” action lists — anything that floats above the page but must not block it.
The magic is that the simplest case needs zero JavaScript. Add the popover attribute to any element, and point a button at it with popovertarget:
<button popovertarget="menu">Actions ▾</button>
<div id="menu" popover>
<button>Rename</button>
<button>Duplicate</button>
<button>Delete</button>
</div>
That button now toggles the popover. When shown, the popover is promoted to the top layer, and — because the default type is auto — it light-dismisses: clicking anywhere outside it, or pressing Esc, closes it. No listeners, no outside-click detection.
There is not a single line of JavaScript in the next demo — the toggling, the light dismiss, and the anchor-based placement are all markup and CSS. Open the code panel to confirm the js section is empty:
auto vs manual (vs hint)
The popover attribute’s value picks its dismissal and stacking behavior:
popover/popover="auto"— light-dismiss on outside click or Esc. Opening anotherautopopover that isn’t nested inside the first one closes the first. This “one at a time” behavior is exactly what you want for menus.popover="manual"— no light dismiss, no auto-closing of siblings. It stays until you callhidePopover()or toggle it off. Use it for things the user shouldn’t lose by clicking away — a toast with an undo button, a persistent side panel.popover="hint"— a third value for hover-triggered hints (tooltips) that don’t close otherautopopovers. It is still rolling out across engines in 2026; treat it as emerging and don’t depend on it in production without a fallback.
<!-- stays open until dismissed by code -->
<div id="toast" popover="manual">
Saved. <button onclick="undo()">Undo</button>
</div>
document.getElementById("toast").showPopover();
setTimeout(() => document.getElementById("toast").hidePopover(), 4000);
The contrast with auto is the whole point: click “Save” and the toast appears, but clicking elsewhere does not dismiss it — only the timer or the Undo button (both hidePopover() calls) can.
The JavaScript surface
Three methods on any element carrying the popover attribute:
el.showPopover(); // promote to top layer
el.hidePopover(); // remove, set display: none
el.togglePopover(); // flip; togglePopover(true) forces show
And two events, both ToggleEvent instances with oldState / newState of "open" or "closed":
menu.addEventListener("beforetoggle", (e) => {
if (e.newState === "open") loadMenuItems(); // fires before it shows
});
menu.addEventListener("toggle", (e) => {
console.log(e.oldState, "→", e.newState); // fires after
});
beforetoggle is your hook for lazy-loading content the instant before a popover opens. The buttons also take popovertargetaction="show|hide|toggle" if you want a dedicated close button rather than a toggle.
Stacking: last opened, first closed
Multiple top-layer elements stack in the order they opened, and Esc / light dismiss peels them off in reverse. Open a menu, then open a submenu popover nested inside it, then a confirmation dialog on top — Esc closes them one at a time, newest first, exactly like a stack.
Anchor positioning, briefly
A popover shown by default appears centered-ish and needs positioning to sit next to its trigger. You can do it with JavaScript, but CSS anchor positioning does it declaratively and keeps the popover glued to the trigger as the page scrolls:
.trigger { anchor-name: --menu-btn; }
#menu {
position-anchor: --menu-btn;
position-area: bottom span-right; /* below, aligned to the right edge */
margin-top: 6px;
}
anchor-name labels the trigger; position-anchor and position-area place the popover relative to it. There’s also @position-try for automatic flipping when the popover would overflow the viewport.
Which one do I use?
The decision is almost always about modality: does this thing need to block the rest of the page until the user deals with it?
Quick rules:
- Confirmations, required forms, “are you sure” alerts → modal
<dialog>withshowModal(). The page should be blocked. - Menus, date pickers, autocomplete, action lists, tooltips →
popover="auto". The user can dismiss by clicking away; that’s expected. - Toasts, undo bars, persistent panels →
popover="manual", dismissed by code or a close button. - You can combine them: a
<dialog popover>gets light-dismiss on a non-modal dialog, and a modal dialog can contain popovers that stack above it.
Accessibility: still your job (but less of it)
The primitives handle mechanics — focus, inertness, escape — but they don’t write your labels.
- Name the dialog. A modal needs an accessible name. Point
aria-labelledbyat its heading, or usearia-label. Without it, screen readers announce “dialog” and nothing else. - Use real buttons.
popovertargetonly works on<button>and<input type="button">, and that’s a feature: you get keyboard activation and the automaticaria-expanded/aria-detailsrelationship for free. Don’t rebuild triggers out of<div>s. autofocusthe right control. A modal moves focus to its first focusable element by default. If that’s a destructive “Delete”, addautofocusto the safer “Cancel” instead so an eager Enter doesn’t nuke anything.- Don’t put
tabindexon the<dialog>. It isn’t an interactive control; let the browser manage focus into its contents. alertdialogfor interruptions. For an error or confirmation that demands a response, setrole="alertdialog"so assistive tech treats it more urgently than a plain dialog.
Summary
- The top layer is a browser-managed plane above every stacking context. Elements promoted to it ignore
z-indexentirely — which is what ends the z-index wars. <dialog>is Baseline-wide (since March 2022). Open modals withshowModal()to get a::backdrop, aninertpage, a real focus trap, focus restore, and Esc for free.show()and bareopengive a non-modal dialog with none of that.- A
<form method="dialog">closes its dialog on submit without navigating, and puts the submitting button’s value indialog.returnValue. Esc does not setreturnValue. closedby(none/closerequest/any) makes dismissal explicit and adds outside-click closing — newer, so feature-detect it.- The Popover API (Baseline newly, January 2025) is the non-modal tool.
popover+popovertargetgives a working, light-dismissing menu with zero JavaScript.autolight-dismisses and closes siblings;manualstays until code hides it;hintis still emerging. - Control popovers in JS with
showPopover()/hidePopover()/togglePopover(), and hookbeforetoggle/toggle. - CSS anchor positioning tethers a popover to its trigger declaratively; core support is Baseline 2026 but flip-fallbacks lag, so treat it as progressive enhancement.
- Choose by modality: block the page → modal
<dialog>; float without blocking →popover. You still write the accessible name and pick where focus lands.