I have implemented the HTML Popover API in my custom browser, Omoikane (source).
With the Popover API, you can open and close popovers using only HTML, without needing to manage their visibility state with JavaScript.
<button popovertarget="menu">Open menu</button>
<div id="menu" popover>
<button>Item 1</button>
<button>Item 2</button>
</div>
At first glance, it seems all you’d need to do is add an API to toggle the element’s display state.
In reality, popovers are rendered on a top layer that lies outside the normal stacking order. There are considerations such as light dismissal when clicking outside, closing with the Escape key, focus movement and restoration, ::backdrop, and the ordering relationship with dialogs.
The implementation went beyond just the DOM API; it also involved CSS, layout, painting, hit‑testing, and even handling actual user input.
This concerns Issue #684 (issue #684) and Pull Request #761 (PR #761).
auto and manual
The popover attribute mainly has the values auto and manual.
If the value is omitted, it defaults to auto.
<div popover>auto popover</div>
<div popover="auto">auto popover</div>
<div popover="manual">manual popover</div>
auto behaves similarly to a typical menu or auxiliary display.
When an unrelated auto popover is opened, any previously opened popover is closed. However, if a popover opens another popover from within its content, they are stacked while preserving their parent‑child relationship.
Both light dismissal by clicking outside and pressing Escape will close it.
manual does not close automatically. Multiple manual popovers can be opened simultaneously. The visibility state is managed explicitly by the page.
In Omoikane, each Document maintains a stack of auto popovers that are currently open, and only popovers that are unrelated to the element being opened are closed from the top of that stack.
Opening and closing via JavaScript
From JavaScript, you can use the following three APIs.
const menu = document.querySelector("#menu");
menu.showPopover();
menu.hidePopover();
menu.togglePopover();
togglePopover() also accepts force.
menu.togglePopover({ force: true });
menu.togglePopover({ force: false });
If the element lacks the popover attribute or is not attached to a Document, it returns the corresponding DOMException.
Even when the same operation is re‑entered while a popover is opening or closing, the state is not changed twice.
Declarative control from buttons
As shown in the initial example, you can specify the target with popovertarget from buttons or certain input elements.
<button popovertarget="menu" popovertargetaction="toggle">
Toggle
</button>
<button popovertarget="menu" popovertargetaction="show">
Open
</button>
<button popovertarget="menu" popovertargetaction="hide">
Close
</button>
If popovertargetaction is omitted, it defaults to toggle.
We also added the corresponding IDL property.
button.popoverTargetElement = menu;
button.popoverTargetAction = "show";
We have connected Omoikane’s default click action to the popover’s open/close handling. Not only does the attribute exist, but we also implemented it so that it works when a button is actually clicked with the mouse.
beforetoggle and toggle
When the visibility state changes, beforetoggle and toggle events are fired.
menu.addEventListener("beforetoggle", event => {
console.log(event.oldState, event.newState);
});
menu.addEventListener("toggle", event => {
console.log(event.oldState, event.newState);
});
The beforetoggle event that occurs just a popover is about to open can be canceled.
menu.addEventListener("beforetoggle", event => {
if (event.newState === "open" && !canOpen()) {
event.preventDefault();
}
});
toggle notifies asynchronously. If opening and closing occur within the same task, the notifications are merged, returning both the initial and final states.
When a popover is opened from an element such as a button, you can also retrieve the originating element via ToggleEvent.source.
The top layer is not a large z‑index
Popovers are rendered on the top layer.
The top layer is a display area that sits above the normal stacking context. No matter how large a z-index value you assign to elements on the page, they will not appear in front of popovers that are on the top layer.
<div class="normal">Normal element</div>
<div id="menu" popover>popover</div>
.normal {
position: fixed;
z-index: 2147483647;
}
#menu::backdrop {
background: rgb(0 0 0 / 40%);
}
In this case, the stacking order is as follows.
popover
::backdrop
Normal element with maximum z‑index
Page
Simply assigning a large z-index to a popover can cause it to be trapped within its ancestor’s stacking context. In the top layer, the stacking order must be handled separately from the normal box‑tree ordering.
Storing top‑layer state in the DOM
In Omoikane, each element carries the following states:
- Whether it is opened as a popover
- Whether it is opened as a modal dialog
- The order in which it entered the top layer
Popovers and modal dialogs share the same top layer.
Each time an element enters the top layer, it is assigned a monotonically increasing number, and the ordering determines their relative stacking. When Escape is pressed, the most foreground dismissable element among the open popovers and dialogs is selected and closed.
When an element is removed from the DOM, its top‑layer state—including that of all its descendants—is cleared, ensuring that a disconnected popover does not remain rendered.
Temporarily removing from normal layout
During layout, elements that belong to the top layer are not positioned according to their normal DOM locations.
First, the regular Document is laid out, and then elements in the top layer are collected. They are ordered by insertion order and added to the layout result directly under the Document as positioned elements relative to the viewport.
Layout a normal Document
-> Skip top‑layer elements
-> Collect top‑layer elements
-> Layout in insertion order
-> Add after normal elements
This ensures that even if a popover is deeply nested in the DOM, it is not confined by its ancestors’ overflow or stacking contexts.
Paint and hit‑testing also use the same ordered layout tree. This prevents a situation where the popover is visually drawn on top while clicks pass through to elements behind it.
Drawing the backdrop just before the popover
::backdrop is also not a regular child element.
When rendering a top‑layer element, we first obtain the computed style of that element’s ::backdrop and draw it as a generated box that covers the entire viewport, serving as the backdrop.
Backdrop of top‑layer item 1
top layer item 1
Backdrop of top‑layer item 2
top layer item 2
Even when multiple top‑layer elements exist, each backdrop and its corresponding main element are kept in insertion order.
On the CSS side, we added ::backdrop as well as :popover-open and :modal. The backdrop is not displayed while the popover is closed; only when it is open are the UA style and author styles applied.
Focus and dismissal
When a popover is opened, if an element with autofocus exists, focus is moved to that element.
Before closing, the original focus location is saved, and if the popover is closed while focus is inside it, focus is returned to the element that was focused before the popover opened.
For light dismissal, both the point where the pointer was pressed and the point where it was released are examined.
If the press and release both occur inside the same popover, it does not close. If the interaction is completed outside the popover, any auto popover that lies above that position is closed. This prevents accidental dismissal when the pointer is simply moved across the popover’s boundary while remaining pressed.
For Escape, we compare the top‑layer order of open auto popovers and modal dialogs, and close the most foreground one.
At this point, the implementation is less about merely supporting the Popover API and more about handling the browser’s input and display state as a whole.
Verification
We did not stop after calling only the DOM API; we also verified the behavior together with actual user input and rendering.
- Opening and closing of
autoandmanual - Mutual exclusivity of popovers that are unrelated to nesting
- Cancellation of
beforetoggleand asynchronous notification fortoggle - Declarative activation from button and input elements
- Focus movement and restoration
- Escape and light dismissal
- Z-order of dialog and popover on the top layer
- Cleanup of subtrees that have been detached from the DOM
:popover-open,:modal,::backdrop- Layout, paint, hit testing
In the fixed WPT, the two added popover‑related files and all 42 subtests passed. The entire WPT consists of 119 tests with zero regressions.
For visual verification, we used a fixed 160×120 fixture. We confirmed with screenshots that the normal maximum z-index element is covered by the backdrop, and the popover appears on top of it. The difference from the saved baseline was also zero.
In the regular tests, 35 test binaries and 2,496 library tests succeeded, and the CI for the three environments reported zero failures.
Summary
This time we implemented the Popover API in Omoikane.
At first it seemed to be a case of simply adding showPopover() and hidePopover(), but that alone does not create a popover in the browser.
We store the visibility state in the DOM, detach it from the normal layout and place it on the top layer, then render a backdrop. We also connected focus handling, Escape, light dismissal, declarative button interactions, ordering with dialogs, and hit testing to the same state.
The Popover API is a relatively new HTML API, but internally it goes through the full browser pipeline of DOM, CSS, layout, paint, and input processing.
Even a feature that appears small tends to expand considerably when implemented as a browser feature.