10LOC
Chrome APIsintermediate

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. The source option is what makes this equivalent to a declarative popovertarget relationship — it tells the browser which element invoked the popover, which matters for CSS anchor positioning (position-anchor) and accessibility wiring.
  • The toggle event fires after the state actually changes, with newState set to "open" or "closed" — the same event <dialog> and <details> also fire.
  • aria-expanded is synced manually here. When a popover is opened via the declarative popovertarget attribute 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 popovertarget HTML attribute. Open it via showPopover()/togglePopover() from a plain click handler (as here) and you're responsible for returning focus to the trigger yourself — that's what the trigger.focus() call is doing.
  • popover="auto" popovers close each other, but a popover="manual" one won't be dismissed by anything except an explicit hidePopover() call or a commandfor/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's source option to place a popover relative to its trigger without a positioning library.