Four Ways to Use the Popover API - From Declarative to Imperative
·
I've been working on a native HTML+CSS pattern library, covering about 60 different components. One thing I noticed as I worked my way through the list is how often the Popover API came up. Specifically, in popovers, tooltips, hover cards, and context menus.
The Popover API is one of the most versatile additions to the web platform in recent years. A single attribute - popover - gives any element top-layer promotion, light-dismiss behavior, and accessible focus management for free. But the API is not one-size-fits-all. Depending on the interaction pattern, you can lean entirely on HTML attributes, mix in a little JavaScript, or take full manual control.
This post walks through four real components that each use the Popover API differently, arranged from the most declarative to the most imperative.
1. Popover
The simplest use of the Popover API requires zero JavaScript. A popovertarget attribute on the button points at the popover's id, and the browser wires up all the toggle logic automatically.
<button type="button" popovertarget="p1">Open popover</button>
<div id="p1" popover>
<h4>Dimensions</h4>
<p>Set the dimensions for the layer.</p>
</div>[popover] {
inset: unset;
}
The bare popover attribute defaults to auto mode, which gives you two behaviors for free: light-dismiss (clicking outside closes the popover) and exclusive stacking (opening one auto popover closes any other). The popovertarget attribute defaults to toggle behavior — click once to open, click again to close. The CSS resets inset: unset because the browser applies a default inset: 0 to popover elements, which would center them in the viewport rather than letting us position them near the trigger.
2. Tooltip
Tooltips need popover="manual" because they should never light-dismiss — the user did not click to open them, so clicking elsewhere should not be intercepted. They also should not compete with other popovers for exclusive stacking; a tooltip and a dropdown menu should be able to coexist.
<button type="button" popovertarget="t1" popovertargetaction="toggle">Hover me</button>
<div id="t1" popover="manual" role="tooltip">Add to library</div>
[role="tooltip"] {
inset: unset;
}
const button = document.querySelector("[popovertarget]");
const tooltip = document.querySelector("[popover]");
positionPopover(tooltip, button, { placement: "top", gap: 4 });
button.addEventListener("mouseenter", () => tooltip.showPopover());
button.addEventListener("mouseleave", () => tooltip.hidePopover());
button.addEventListener("focus", () => tooltip.showPopover());
button.addEventListener("blur", () => tooltip.hidePopover());
Since manual mode disables all built-in triggers, we call showPopover() and hidePopover() from four event listeners: mouseenter/mouseleave for hover, and focus/blur for keyboard accessibility. The popovertargetaction="toggle" on the button is a fallback that lets the tooltip also work via click if the JavaScript has not loaded yet. The role="tooltip" ensures screen readers announce the content as a tooltip description rather than a generic region.
3. Hover Card
Hover cards are richer than tooltips — they contain structured content like profile information or previews, and the user should be able to move their mouse into the card to interact with it. Unlike the tooltip, a hover card uses popover="auto" because it benefits from light-dismiss: if the user clicks somewhere else, the card should close.
<a href="#" popovertarget="my-hovercard" popovertargetaction="show">@dustinboston</a>
<div id="my-hovercard" popover="auto">
<h4>Dustin</h4>
<p>Software engineer. Joined March 2002.</p>
</div>
[popover] {
inset: unset;
position: fixed;
}
let hideTimeout;
const show = () => { clearTimeout(hideTimeout); card.showPopover(); };
const hide = () => { hideTimeout = setTimeout(() => card.hidePopover(), 100); };
button.addEventListener("mouseenter", show);
button.addEventListener("mouseleave", hide);
card.addEventListener("mouseenter", show);
card.addEventListener("mouseleave", hide);
positionPopover(card, button, { placement: "top" });
The popovertargetaction="show" on the anchor is a subtle detail — it means if a user clicks the link, the card is shown rather than toggled closed. The real runtime drivers are the mouse event listeners, but there is an important difference from the tooltip: a delayed hide. When the cursor leaves the trigger, a 100ms timeout starts before hidePopover() fires. If the cursor enters the card within that window, the timeout is cancelled. This lets the user cross the gap between the trigger and the card without the card flickering away. Both the trigger and the card share the same show/hide functions, so hovering either element keeps it open. This is the hybrid sweet spot: the element still gets top-layer promotion and auto-mode light-dismiss from the platform, while JavaScript only handles the hover timing.
4. Context Menu
A right-click context menu is the furthest from the declarative ideal. There is no trigger button to wire popovertarget to — the menu appears wherever the cursor happens to be when the user right-clicks. This calls for popover="manual".
<div id="context-menu">
<div id="trigger">Right click here</div>
<div id="menu" role="menu" popover="manual">
<button role="menuitem" type="button">Back</button>
<button role="menuitem" type="button">Forward</button>
<button role="menuitem" type="button">Reload</button>
<hr data-separator />
<button role="menuitem" type="button">Save As...</button>
<button role="menuitem" type="button">Print...</button>
</div>
</div>
[popover] {
inset: unset;
position: fixed;
}
const trigger = document.querySelector("#trigger");
const menu = document.querySelector("#menu");
trigger.addEventListener("contextmenu", (e) => {
e.preventDefault();
menu.style.left = e.clientX + "px";
menu.style.top = e.clientY + "px";
menu.showPopover();
});
document.addEventListener("click", (e) => {
if (!menu.contains(e.target)) menu.hidePopover();
});
Manual mode disables every automatic behavior: no light-dismiss, no Escape-to-close, no exclusive stacking. The contextmenu event provides the cursor coordinates, which are set as inline style.left and style.top before calling showPopover(). Dismissal is handled by a document-level click listener that checks whether the click landed outside the menu. The component is responsible for the entire lifecycle — but it still benefits from top-layer rendering, which means it paints above dialogs and other popovers without z-index hacks or stacking context headaches.
The Spectrum
These four components sit on a clear spectrum:
Popover - fully auto, no JS required
Tooltip - manual, minimal JS required
Hover Card - auto, moderate JS required
Context Menu - manual, full JS required
The lesson is straightforward: start with popover and popovertarget. Add popovertargetaction if you need directional control. Reach for showPopover() and hidePopover() only when the trigger is not a click. Switch to manual only when you need to own the entire lifecycle. At every step, the platform is doing the heavy lifting — you are just choosing how much of it to use.