The Popover API for a dismissible popover, no positioning library
Published August 28, 2026
// togglePopover's { source } option is current spec but not yet in TypeScript's DOM lib.
type TogglePopover = (options?: { source?: HTMLElement; force?: boolean }) => void;
const menu = document.querySelector<HTMLElement>("#user-menu")!;
const trigger = document.querySelector<HTMLButtonElement>("#user-menu-trigger")!;
trigger.addEventListener("click", () => {
(menu.togglePopover as TogglePopover)({ source: trigger });
});
menu.addEventListener("toggle", (event) => {
const { newState } = event as ToggleEvent;
trigger.setAttribute("aria-expanded", String(newState === "open"));
if (newState === "closed") trigger.focus();
});What
The popover global attribute promotes an element to the browser's top layer — above everything else, no z-index needed — and gives it light-dismiss (click outside or press Esc to close) and only-one-open-at-a-time behavior, all without JavaScript.
Why it matters
Every dropdown/menu/tooltip library reimplements the same three things by hand: a click-outside listener (that has to ignore clicks on the trigger), an Esc keydown listener, and a z-index/portal strategy so the popover isn't clipped by an ancestor's overflow: hidden. The Popover API gives you all three from the browser's own top-layer rendering, the same mechanism <dialog> uses for modals.
popover="auto" (the default when you write just popover) is the one you want for menus and dropdowns: opening one closes any other open auto popover, and it dismisses on outside click or Esc automatically. popover="manual" opts out of light-dismiss for things like a persistent toast. popover="hint" is for content shown on hover/focus (tooltip-style) that shouldn't compete with auto popovers for the dismiss stack.
How it works
The snippet wires up a menu declared as <div popover id="user-menu"> opened by a separate button, rather than the declarative popovertarget attribute:
trigger.togglePopover({ source: trigger })opens/closes the popover programmatically. Thesourceoption is what makes this equivalent to a declarativepopovertargetrelationship — it tells the browser which element invoked the popover, which matters for CSS anchor positioning (position-anchor) and accessibility wiring.- The
toggleevent fires after the state actually changes, withnewStateset to"open"or"closed"— the same event<dialog>and<details>also fire. aria-expandedis synced manually here. When a popover is opened via the declarativepopovertargetattribute instead, the browser manages some of this wiring (and the focus-return-to-invoker behavior below) for you automatically.
Gotchas
- Focus-return-on-close is automatic only when the popover/invoker relationship is declared via the
popovertargetHTML attribute. Open it viashowPopover()/togglePopover()from a plain click handler (as here) and you're responsible for returning focus to the trigger yourself — that's what thetrigger.focus()call is doing. popover="auto"popovers close each other, but apopover="manual"one won't be dismissed by anything except an explicithidePopover()call or acommandfor/command="hide-popover"button — Esc and outside clicks don't touch it.- A popover is not automatically made inert-safe for the rest of the page the way a modal
<dialog>is: the background stays fully interactive. If you need to block interaction with the rest of the page, you want<dialog>, not a popover.
Browser support: the Popover API is Baseline — supported in current Chrome, Edge, Firefox, and Safari. popover="hint" is the newest of the three values and worth a quick compatibility check if you're targeting older releases of any engine.
Related
<dialog>— for a modal overlay that should block interaction with the rest of the page; popovers don't do that.- CSS anchor positioning — pairs naturally with
popover'ssourceoption to place a popover relative to its trigger without a positioning library.