Popover API and Native <dialog>: The End of Hand-Built Modals
Popover API and Native <dialog>: The End of Hand-Built Modals
In the article on accessible components, I showed a modal that visually disappeared via display: none but was still sitting in the accessibility tree, because someone hid it incorrectly. That was just one of five problems a modal built from scratch on a <div> has to solve manually to count as accessible at all: a focus trap (Tab can't lead the user outside the modal), closing on Escape, closing on a click outside the content, returning focus to whatever element opened the modal, and actually rendering above the rest of the page, no matter how many components along the way have overflow: hidden or their own z-index.
jsx
// Looks like a modal – doesn't actually solve any of the five problems aboveconst NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
This code has no focus trap — Tab freely walks a keyboard user right back out into the rest of the page, even though the modal is visually covering everything. It doesn't close on Escape. It doesn't return focus to the trigger on close. And it depends on no ancestor in the DOM tree having overflow: hidden or a lower z-index than some other element on the page — an assumption that regularly turns out false in a large app. Libraries like Radix or Headless UI exist precisely to solve this once, properly, in JavaScript. For a few years now, the browser has been solving part of this problem on its own, without a single line of JS.
Top layer: a layer that z-index doesn't touch
The key mechanism behind both <dialog> and the popover attribute is the top layer — an internal rendering layer in the browser, physically above the rest of the document, completely independent of z-index, overflow, or stacking contexts. An element promoted to the top layer doesn't "have a very high z-index" — it's no longer part of the page's normal layer stack at all, so no ancestor with overflow: hidden can clip it, and no sibling element with an absurd z-index: 9999 can cover it. This is the same mechanism browsers have long used internally for native elements like <video> in fullscreen mode — the Popover API and <dialog> simply expose it to developers.
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>Are you sure you want to delete this item?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">Cancel</button><button type="submit" value="confirm" onClick={onConfirm}>Delete</button></form></dialog>);};// elsewhere in the component:// dialogRef.current.showModal();
Calling .showModal() (not .show() — a common mistake, .show() opens a non-modal dialog, with no backdrop and no focus trap) triggers four things at once, for free: promotion to the top layer, rendering of a ::backdrop pseudo-element that dims the rest of the page, a focus trap (Tab cycles only within the dialog), and making the rest of the document inert — elements outside the dialog stop being focusable and disappear from interaction for a screen reader, even though they're still visible under the dimmed backdrop. Closing via Escape or via <form method="dialog"> (a native mechanism — clicking a submit button inside such a form closes the dialog and sets dialog.returnValue to the clicked button's value, with no preventDefault and no JS handler) automatically returns focus to whatever element opened the dialog. None of these four things requires writing it yourself.
Popover API: the same thing, minus modality
<dialog> assumes the rest of the page should be blocked. That's right for a delete confirmation, but too heavy for a dropdown menu, a tooltip, or a toast — things that need to show up above the rest of the content and close themselves on an outside click, but should not block interaction with the rest of the page. That's what the popover attribute is for, declaratively, with not a single line of JavaScript:
html
<button popovertarget="user-menu">Account</button><div id="user-menu" popover><a href="/profile">Profile</a><a href="/settings">Settings</a><button popovertarget="user-menu" popovertargetaction="hide">Log out</button></div>
The popovertarget/id relationship alone is enough for the browser to handle the rest: promotion to the top layer, light dismiss (clicking anywhere outside the popover, or pressing Escape, closes it automatically, with no need to listen for click on document), and correct ARIA relationships between the trigger and the content (the browser wires up aria-expanded and aria-controls on its own, based on that pair of attributes). This is exactly what used to require a library like Floating UI, or manually listening for clicks outside the element with an extra event.target.closest() check.
The fundamental difference from <dialog> in modal mode: a popover does not make the rest of the page inert, and has no focus trap. That's a deliberate spec decision, not a missing feature — a user account menu shouldn't block the ability to scroll or click elsewhere on the page, the way a real modal does. Mixing up these two tools in the wrong direction — using popover where content genuinely needs to block the rest of the interface (e.g. confirming an irreversible operation) — gives you an interface where a keyboard user can tab right out of a "modal" that was never actually blocking them.
A pitfall the docs sometimes leave out: clicking the <dialog> backdrop doesn't close it automatically
Even though ::backdrop renders for free, clicking it does not close the dialog without extra code — this is one of the few things you have to write yourself. The mechanism that makes it possible relies on a specific fact about hit-testing: the <dialog> element itself, in modal mode, fills the visible area for hit-testing purposes regardless of how visually small its content box is — so a click on the dimmed backdrop still hits event.target === dialogElement, while a click on the content inside hits a specific child element.
jsx
const handleBackdropClick = (event) => {// event.target is the <dialog> itself only when the click hit the// backdrop, not any of the content elements inside itif (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
This trick is worth remembering on its own, because the intuition is usually "well, the backdrop renders itself, so surely it closes itself too" — it doesn't, and it's the one piece in this whole set that requires a hand-written line of JS.
Pitfalls and best practices
.show()is not the same as.showModal()..show()opens a non-modal dialog — no::backdrop, no focus trap, noinerton the rest of the page. For a real modal, you always need.showModal().<dialog>'s default styles need to be reset deliberately, just like with any native element from the accessibility article (<button>,<ul>) — the browser gives it a defaultborder,padding, and centering, which in practice you'll almost always override with your own CSS.- Popover doesn't replace
<dialog>where modality is required. The lack of a focus trap and ofinertis a deliberate limitation of popover, not a gap to work around — if content has to block the rest of the page, the right tool is always<dialog>.showModal(). - Support:
<dialog>has been safe for a while,popoveris newer.<dialog>(includingshowModal()) has had solid support since 2022. Thepopoverattribute reached Baseline (widely available) status later, in 2024 — it's still worth checking the minimum supported Safari version in projects with a long tail of older devices before dropping a JS library as a fallback entirely.
Conclusion
A modal built by hand on a <div> isn't bad because the developer didn't try hard enough — it's bad because it's trying to recreate, in JavaScript, a mechanism the browser now offers natively and for free: a top layer independent of z-index, a focus trap, inert on the rest of the page, ::backdrop, returning focus to the trigger. <dialog> and popover aren't two variants of the same thing — they're two tools on opposite sides of the same boundary: modality that deliberately blocks the rest of the interface, and lightweight, temporary content that deliberately doesn't. Choosing between them isn't a matter of style, it's a matter of answering whether the user should be able to do anything else on the page at that moment — and that's a question worth asking before you write the first line of code, not after you discover that your menu blocks scrolling, or that your account-deletion confirmation doesn't block Tab at all.