Skip to content
All posts
#CSS#Architecture#Performance#React

Overlays Without a Library: The Popover API as a Protocol

7 min read

The Popover API is a browser-level overlay protocol: one attribute owns stacking, dismissal, focus order and events — no positioning library, no close listener.

For years the standard way to build a dropdown or action menu was to pull in a positioning library and re-implement the same four behaviours by hand: render the panel, hoist it visually above everything else, wire a document-level listener to close it, and move focus back when it closes. The Popover API replaces that scaffolding with a browser-level protocol — one attribute declares an element as an overlay, and the platform takes over stacking, dismissal, focus order, and the event model that reports what happened.[1][2]

The important word is protocol: a popover is not a widget you configure, but an element whose state the browser owns and you drive through attributes, properties, and events.[2][4]

The Attribute Is the State Machine

Adding the popover attribute to an element designates it as a popover element, and popover elements are hidden with display: none until opened by an invoking control or a showPopover() call.[3] When open, the element is placed in the top layer, so it appears above all other elements and is not affected by a parent's position or overflow styling.[3]

That one behaviour removes the most common source of overlay bugs. A dropdown rendered inside an overflow: hidden card, or beneath an ancestor carrying a transform, is clipped or mispositioned; an element in the top layer is subject to neither.[3]

The attribute takes one of three values, and the value is the interaction contract.[3] popover="auto" can be light-dismissed by clicking outside it or pressing Esc, and showing one auto popover generally closes any other auto popover already displayed. popover="manual" cannot be light-dismissed and is never closed automatically, which is what allows several independent manual popovers to be shown at once. popover="hint" sits between the two: it does not close open auto popovers, but it does close other hint popovers that are not its ancestors.[3] Setting the attribute with an empty value is equivalent to popover="auto".[3]

Dismissal Is a Stack, Not a Listener

The behaviour hand-rolled overlays usually get subtly wrong is the interaction between several simultaneously open panels. The browser maintains two independent stacks — an auto stack and a hint stack — and when a popover is hidden it walks back down the relevant stack, closing descendant popovers on that stack first.[2]

Several specific rules follow, and each is a bug a document-level click handler cannot express. Showing a hint popover does not close auto popovers.[2] Showing a hint popover closes other hint popovers except its ancestors in the hint stack.[2] Clicking outside light-dismisses all open auto and hint popovers that are not its ancestor.[2]

Hiding an auto popover does not close hint popovers that are not its descendants.[2] And showing an auto popover as a child of a hint popover downgrades the auto popover to hint, so a nested tooltip-menu does not silently tear down the menu it lives inside.[2] Manual popovers participate in neither stack.[2]

Auto popovers are also dismissed by a successful showModal() or requestFullscreen() call on another element in the document, which is the browser enforcing one visible top-layer surface at a time.[2]

The Events Describe a Transition, Not a Result

beforetoggle fires just before a popover's state changes; toggle fires just after.[2] Both deliver a ToggleEvent whose oldState and newState properties say which direction the transition went, so a single handler can distinguish opening from closing without tracking a boolean of its own.[7][8]

The two events are not interchangeable. beforetoggle is cancelable when an element is being toggled to open, but not when it is closing, which makes it the correct place to refuse to show a panel and the wrong place to run teardown.[7] toggle is not cancelable at all.[8]

Two sharp edges sit on top of that. Toggle events coalesce: if several toggle events would fire before the event loop has a chance to cycle, only a single event is fired, so a show immediately followed by a hide produces one notification, not two.[8] And the show algorithm refuses reentrancy — calling showPopover(), hidePopover(), or togglePopover() from inside a beforetoggle listener while another popover is already being shown or hidden throws an InvalidStateError.[5][6]

The specification is explicit that this "prevents showing a popover during the show or hide of another popover".[9] It also re-runs its validity check after firing beforetoggle, because the handler could have disconnected the element or changed its popover attribute.[9] A beforetoggle listener that swaps the element to manual, or removes it from the document, therefore changes the outcome of the very show operation that invoked it.

Focus, ARIA, and the Implicit Anchor

When a popover-invoker relationship is established through the popovertarget attribute, the API makes two additional changes for keyboard and assistive-technology users: the keyboard focus navigation order is updated so the popover comes next in the sequence, and closing via the keyboard shifts focus back to the invoker; and an implicit aria-details and aria-expanded relationship is set up between the two elements.[2]

That relationship also creates an implicit anchor reference — the invoker becomes the popover's anchor element, so CSS anchor positioning can target it without declaring anchor-name and position-anchor. You still have to write the positioning CSS.[2]

Establishing the relationship with the source option of showPopover() or togglePopover() is weaker: only the focus navigation order changes, not the implicit ARIA relationship, because source accepts any element and the browser cannot guarantee the relationship would make sense.[2] That is an accessibility trade-off, not an implementation detail.

Where the Protocol Ends and Your Code Begins

Popovers created with the Popover API are always non-modal; the rest of the page stays interactive while one is showing.[1] When the rest of the page must be inert, the dialog element is the right tool, and <dialog popover> is valid if you want dialog semantics combined with popover control.[1] Typical fits for the API itself are action menus, toast notifications, form suggestions, content pickers, and teaching UI.[1]

Support Tiers

The base layer is broadly available: the popover global attribute is supported in Chrome 114, Firefox 125, and Safari 17, and :popover-open matches the same versions.[10][12] The popovertarget and popovertargetaction attributes on <button> share that tier.[13]

The finer-grained pieces are newer. togglePopover() returning a boolean landed in Chrome 116, Firefox 125, and Safari 17; before that it returned undefined, which is why the documented example checks for undefined before using the result.[6][11]

The source option on showPopover() and togglePopover() arrived in Chrome 137, Firefox 144, and Safari 26, and the command/commandfor attribute pair in Chrome 135, Firefox 144, and Safari 26.2.[11][13]

The hint state is the newest layer and is not baseline. Browser-compat-data records Chrome 151 with the 133–151 range flagged as a partial implementation that "implements an older version of the specification with inconsistent behaviors", and Firefox 153 with 149–153 flagged the same way; Safari is still at preview.[10] The web-features entry also carries a 2024 note that a Safari on iOS bug prevented dismissing popovers by touch, which had downgraded the feature's baseline status at the time.[14]

For rollout, treat the API as tiers. Ship auto and manual with popovertarget now, and use beforetoggle for refusal and toggle for post-transition side effects.[4][7][8]

Feature-detect with Object.hasOwn(HTMLElement.prototype, "popover") before touching DOM properties, and hold the source option, command/commandfor, and the hint state as progressive enhancements behind a capability check.[10][11][13]

Sources

[1] https://developer.mozilla.org/en-US/docs/Web/API/Popover_API — MDN — Popover API (overview)

[2] https://developer.mozilla.org/en-US/docs/Web/API/Popover_API/Using — MDN — Using the Popover API

[3] https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover — MDN — popover HTML global attribute

[4] https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/popover — MDN — HTMLElement: popover property

[5] https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/showPopover — MDN — HTMLElement: showPopover() method

[6] https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/togglePopover — MDN — HTMLElement: togglePopover() method

[7] https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforetoggle_event — MDN — HTMLElement: beforetoggle event

[8] https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event — MDN — HTMLElement: toggle event

[9] https://html.spec.whatwg.org/multipage/popover.html — WHATWG HTML Standard — The popover attribute (show/hide algorithms)

[10] https://github.com/mdn/browser-compat-data/blob/main/html/global_attributes.json — mdn/browser-compat-data — html/global_attributes.json (popover, hint)

[11] https://github.com/mdn/browser-compat-data/blob/main/api/HTMLElement.json — mdn/browser-compat-data — api/HTMLElement.json (popover methods & events)

[12] https://github.com/mdn/browser-compat-data/blob/main/css/selectors/popover-open.json — mdn/browser-compat-data — css/selectors/popover-open.json

[13] https://github.com/mdn/browser-compat-data/blob/main/html/elements/button.json — mdn/browser-compat-data — html/elements/button.json (popovertarget, command)

[14] https://github.com/web-platform-dx/web-features/blob/main/features/popover.yml — web-platform-dx/web-features — features/popover.yml