The Popover API: Floating UI menus, tooltips, and toasts
The Popover API gives you a clean way to build floating UI elements like menus, tooltips, action menus, and toast notifications without fighting browser quirks or writing dozens of lines of positioning code. You add a popover attribute to an element, and the browser handles showing it on a top layer above everything else, light-dismissing it when the user clicks outside, and animating it in and out.
It’s one of those APIs that makes you wonder why the web spent twenty years doing this manually.
How it works
Add popover to any element and it becomes a popover:
<button popovertarget="my-menu">Open Menu</button>
<div popover id="my-menu">Menu content here</div>
That’s all you need for a working popover. No JavaScript required, no z-index calculations, no click-outside listeners.
The popover attribute takes three values:
| Value | Behavior |
|---|---|
auto | Light-dismisses when you click outside; only one can be shown at a time |
hint | Like auto but starts hidden without a trigger; more experimental |
manual | Never auto-dismisses; you control visibility entirely via JS or a control button |
Most use cases want auto since it handles the common “click outside to close” behavior automatically.
Toggling via HTML
The simplest path: a <button> with popovertarget:
<button popovertarget="action-menu" popovertargetaction="toggle">Actions</button>
<div popover="auto" id="action-menu">
<button onclick="share()">Share</button>
<button onclick="edit()">Edit</button>
<button onclick="delete()">Delete</button>
</div>
popovertarget identifies the popover element. popovertargetaction can be "show", "hide", or "toggle" (default). The button shows the popover on click and hides it on the next click. While the HTML attributes handle simple cases, you will often need JavaScript for programmatic control—for example, toggling based on application state or responding to events beyond clicks.
Toggling via JavaScript
For programmatic control, use the instance methods on any HTMLElement:
const popover = document.getElementById("action-menu");
// Show it
popover.showPopover();
// Hide it
popover.hidePopover();
// Toggle it
popover.togglePopover();
// Check state
console.log(popover.popover); // "auto" | "hint" | "manual" | "" (empty = not a popover)
showPopover() throws if the element is already showing. hidePopover() throws if it’s already hidden. togglePopover() handles both cases without throwing. These methods give you precise programmatic control, but real interfaces often need to prepare data or cancel transitions before the popover actually opens. That is where beforetoggle comes in.
The beforetoggle Event
beforetoggle fires before the popover changes state. This is where you can cancel the transition, fetch data before opening, or update UI synchronously:
const popover = document.getElementById("toast");
popover.addEventListener("beforetoggle", (event) => {
if (event.newState === "open") {
console.log("Popover about to open");
// Could call event.preventDefault() to cancel
} else {
console.log("Popover about to close");
}
});
event.newState is "open" or "closed". Call event.preventDefault() in beforetoggle to block the transition. This is useful when you need to validate input or fetch remote data before allowing the popover to appear. The companion toggle event fires after the change is committed, giving you a clean point to update UI or start timers.
The toggle Event
toggle fires after the state change completes:
popover.addEventListener("toggle", (event) => {
if (event.target.matches(":popover-open")) {
console.log("Popover is now showing");
startAutoHideTimer();
} else {
console.log("Popover is now hidden");
}
});
You can check :popover-open inside the handler to know the new state. Alternatively, event.newState would be "open" or "closed" if ToggleEvent exposes it.
Light Dismiss
This is the killer feature. With popover="auto", clicking outside the popover automatically closes it. No JavaScript, no event listeners, no manually tracking click coordinates.
This covers:
- Clicking anywhere outside the popover tree
- Pressing Escape (which also fires
beforetoggleandtoggle) - Opening another popover (only one
autopopover can be open at a time)
For popover="manual", none of this happens. The popover stays open until you call hidePopover() or use a control button with popovertargetaction="hide".
Backdrop Styling
When a popover is showing, you can style the area behind it using ::backdrop:
/* Dim the background */
#action-menu::backdrop {
background: rgba(0, 0, 0, 0.4);
}
/* Blur the page behind a tooltip */
#tooltip::backdrop {
background: transparent;
backdrop-filter: blur(2px);
}
The ::backdrop pseudo-element only appears while the popover is showing. It’s the same mechanism that <dialog> uses for its modal backdrop. You can apply any CSS property to the backdrop, including gradients, filters, and animations. The backdrop sits between the popover and the rest of the page in the top layer.
While the backdrop handles the space behind the popover, styling the popover itself uses a different pseudo-class.
Styling the Popover
Use the :popover-open pseudo-class to style a popover when it’s visible:
/* Only visible when showing */
#action-menu {
padding: 1rem;
border: 1px solid #ccc;
background: white;
}
#action-menu:popover-open {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
The difference from just using a class is that :popover-open only matches while the element is in the showing state, so there is no need to manually add or remove classes when toggling.
Showing and hiding via JavaScript
Putting it together: a toast notification system using popovers:
function showToast(message, duration = 3000) {
const toast = document.getElementById("toast");
// Set message
toast.textContent = message;
// Show it
toast.showPopover();
// Auto-hide after duration
setTimeout(() => {
toast.hidePopover();
}, duration);
}
// Attach to buttons
document.querySelectorAll("[data-toast]").forEach(btn => {
btn.addEventListener("click", () => {
showToast(btn.dataset.toast);
});
});
The showToast function takes a message string and an optional duration in milliseconds. It sets the popover’s text content, calls showPopover() to display it, then schedules hidePopover() with setTimeout. The button click handler reads from data-toast attributes, which keeps the markup declarative while the logic stays in JavaScript.
<button data-toast="Saved!">Save</button>
<div popover="auto" id="toast" style="margin: 0;"></div>
No positioning code. No z-index juggling. The browser places the popover and handles dismissal. This toast pattern works because the showToast function encapsulates all the timing logic, keeping call sites short. The same approach applies to menus, where you typically want item selection to close the popover and trigger an action in one flow.
Menu Example
A common pattern: an action menu that opens on click:
const menuButton = document.getElementById("menu-btn");
const menu = document.getElementById("action-menu");
menuButton.addEventListener("click", () => {
menu.togglePopover();
});
// Close when clicking a menu item
menu.addEventListener("click", (e) => {
if (e.target.tagName === "BUTTON") {
menu.hidePopover();
// Handle the action
handleAction(e.target.dataset.action);
}
});
// Close on Escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && menu.matches(":popover-open")) {
menu.hidePopover();
}
});
With popover="auto" you don’t strictly need the Escape handler since the browser handles it, but it’s good practice for consistency. Once a menu is working, the next natural step is nesting: a submenu that appears inside an already-open popover without closing the parent.
Nesting Popovers
You can nest popovers. A submenu inside a menu:
<div popover="auto" id="menu">
<button popovertarget="submenu" popovertargetaction="toggle">More ▾</button>
</div>
<div popover="auto" id="submenu">
<button>Option A</button>
<button>Option B</button>
</div>
Opening the submenu doesn’t close the parent menu (they’re both auto). Closing the parent closes the submenu too, since it’s no longer in the DOM tree of an open popover.
Popover vs Dialog
The Popover API and <dialog> overlap in some ways but serve different purposes:
| Feature | Popover | <dialog> |
|---|---|---|
| Light dismiss | Auto popovers only | No — always modal unless modal="false" |
| Top layer | Yes | Yes |
| Backdrop styling | ::backdrop | ::backdrop |
showModal() | No | Yes |
| Accessibility | Role guesswork, limited | Full dialog semantics |
| Browser support | Chrome 114+, Edge 114+, Firefox 125+, Safari 17.1+ | Everywhere |
Use popover for non-modal floating UI like menus, tooltips, and toasts. Use <dialog> for actual dialogs that should block interaction with the rest of the page.
You can combine them: <dialog popover> gives you dialog semantics with popover light-dismiss behavior.
Feature Detection
Check if the API is available:
if ("showPopover" in HTMLElement.prototype) {
console.log("Popover API supported");
} else {
// Fallback: hide/show with CSS classes
}
This runtime check returns true in browsers that support the Popover API. You can pair it with a class-based fallback that shows and hides elements through display or visibility when the API is unavailable.
You can also detect support in CSS with @supports, which is useful for progressive styling without JavaScript:
@supports (popover: auto) {
/* Use popover */
}
Chrome 114+, Edge 114+, Firefox 125+, Safari 17.1+. Firefox and Safari support landed in 2024. With feature detection in place, you can build components that degrade gracefully. The next section shows a complete example that ties together the methods, events, and styling covered so far.
Full example: custom select
Here’s a popover-based custom select that shows a list of options:
class PopoverSelect {
constructor(buttonEl, options) {
this.button = buttonEl;
this.options = options;
this.popover = this.createPopover();
this.value = null;
this.button.addEventListener("click", () => {
this.popover.togglePopover();
});
}
createPopover() {
const el = document.createElement("div");
el.popover = "auto";
el.innerHTML = this.options
.map(opt => `<button type="button" data-value="${opt.value}">${opt.label}</button>`)
.join("");
el.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
this.value = btn.dataset.value;
this.button.textContent = btn.textContent;
el.hidePopover();
this.onChange(this.value);
});
document.body.appendChild(el);
return el;
}
onChange(value) {}
}
// Use it
const select = new PopoverSelect(document.getElementById("country-btn"), [
{ value: "us", label: "United States" },
{ value: "gb", label: "United Kingdom" },
{ value: "de", label: "Germany" },
]);
select.onChange = (value) => {
console.log("Selected:", value);
};
No positioning math, no scroll listeners, no click-outside handlers. The browser handles the hard parts.
Choose the right popover mode
auto is the best starting point for menus and simple floating panels because it gives you light-dismiss behavior out of the box. manual fits cases where you want the popover to stay open until code closes it. hint is more specialized and should be used only when you know its behavior matches the interaction you need. Picking the mode up front saves a lot of later adjustments.
Keep keyboard flow clear
A popover should not trap the user in a confusing tab order. Make the focus target obvious, close on Escape, and let the next tab stop make sense after the popover hides. Good keyboard behavior matters just as much as mouse behavior, especially for menus that sit near the top of the page.
Pair popovers with plain UI when needed
Popover is a tool, not a rule. For a tooltip, a menu, or a small inline panel, it removes a lot of plumbing. For richer interaction, you may still want a custom component with more explicit state. That judgment call keeps the feature aligned with the user task rather than the browser feature list.
Check fallback paths
If the browser does not support the API, the page should still work with a simple class toggle or hidden panel. A graceful fallback keeps the interaction usable in older browsers and makes it practical to layer richer behavior onto a working base. The feature should feel like a bonus, not a lock-in.
Default to the smallest useful pattern
A popover is a good fit when you want floating content without modal behavior. Menus, quick actions, and short hints are all natural matches. If the panel needs a full workflow or a heavy amount of state, a custom component may be clearer. Picking the smallest tool that fits the task keeps the UI easier to maintain.
Test keyboard and pointer paths
A popover should work the same whether the user clicks, taps, or uses the keyboard. Try the feature with each path so you know dismissal, focus, and selection all behave as expected. That kind of check catches awkward behavior early and keeps the interaction feeling consistent.
Fallbacks should stay simple
If the browser does not support popovers, a plain hidden panel is usually enough as a fallback. Keep the fallback code small so the main feature stays easy to read and the non-support path does not become its own project. Simple is good here because the API is already doing the heavy lifting.
Keep the trigger obvious
The control that opens a popover should look like a control. That makes the interaction easier to discover and helps users understand what will happen next. A clear trigger is part of the feature, not an extra decoration on top of it.
See Also
- /guides/javascript-shadow-dom/: combine with shadow DOM for encapsulated popover internals
- /guides/javascript-navigation-api/: SPAs often use popovers alongside a router
- /guides/javascript-pwa-guide/: PWAs can use the Popover API for in-app menus and toasts