EdgeCases Logo
Feb 2026
Browser APIs
Surface
6 min read

HTML Popover API: The Hidden Gotchas

The declarative popover attribute escapes z-index hell—until nested menus clash, light dismiss misfires, and focus management surprises you.

html
popover
browser-apis
accessibility
z-index
edge-case

The HTML popover attribute promises escape from z-index hell, positioning libraries, and manual focus management. Add an attribute, wire up a button, done. Except when your popover disappears unexpectedly, nested menus fight each other, or focus jumps somewhere bizarre.

The Top Layer: Why Z-Index "Just Works"

Popovers render in the top layer—a special stacking context above everything else. No z-index: 9999 wars. But this creates its own surprises.

<div style="position: relative; z-index: 1">
  <button popovertarget="tooltip">Hover me</button>
  <div id="tooltip" popover>
    I'm in the top layer. Your z-index means nothing.
  </div>
</div>

The top layer ignores parent stacking contexts entirely. A popover inside a z-index: 1 container still renders above a z-index: 99999 modal. This is usually what you want—until you need a popover inside a modal.

The Modal + Popover Problem

<dialog id="modal">
  <button popovertarget="dropdown">Select option</button>
  <div id="dropdown" popover>
    <!-- Opens above the modal, but click outside closes it -->
  </div>
</dialog>

Both <dialog> and popover use the top layer. The popover appears above the modal (correct), but "light dismiss" treats clicks on the modal as "outside" and closes the popover (often wrong). Solutions:

  • Use popover="manual" for popovers inside modals
  • Handle the beforetoggle event and preventDefault() selectively
  • Nest via the anchor relationship (more on this below)

Auto vs Manual: When Light Dismiss Hurts

popover="auto" (the default) enables "light dismiss"—click outside, press Escape, or open another auto popover, and it closes. Convenient, until:

// User clicks a button inside the popover
button.addEventListener('click', () => {
  // Fetch data, show loading state...
  // Oops, click event bubbled and "light dismissed" the popover
});

The fix is usually to stop propagation carefully, but this breaks other event patterns. Consider popover="manual" when your popover has interactive content that shouldn't trigger dismissal.

Auto Popovers Fight Each Other

<button popovertarget="menu1">Menu 1</button>
<div id="menu1" popover>First menu</div>

<button popovertarget="menu2">Menu 2</button>
<div id="menu2" popover>Second menu</div>

Opening menu2 auto-closes menu1. That's the spec: only one auto popover at a time (unless nested). If you need multiple simultaneous popovers, use popover="manual" on all of them.

Nested Popovers: Three Ways to Establish Hierarchy

The "one auto popover" rule has an exception: nested popovers can all stay open. But "nested" has a specific meaning. Three patterns work:

1. DOM Descendants

<div id="parent" popover>
  Parent
  <div id="child" popover>Child stays open with parent</div>
</div>

2. Invoker Inside Parent

<div id="parent" popover>
  <button popovertarget="child">Open child</button>
</div>
<div id="child" popover>I'm nested via invoker</div>

The child's invoker button is inside the parent, establishing the relationship.

3. Anchor Attribute

<div id="parent" popover>Parent content</div>
<div id="child" popover anchor="parent">Anchored child</div>

The anchor attribute establishes nesting without DOM structure. This also enables CSS anchor positioning (position-anchor).

The Gotcha: Order Matters

// Opening order determines what closes what
parent.showPopover();  // Opens
child.showPopover();   // Opens (nested, both stay)

// But if you open child first:
child.showPopover();   // Opens
parent.showPopover();  // ❌ Closes child! Parent isn't nested "under" child

The nesting relationship is established at show time. Opening a "parent" after its "child" reverses the expected behavior.

Focus Management Surprises

The API updates keyboard navigation order when a popover opens—the popover becomes next in tab sequence. But there's no focus trapping by default.

<div id="menu" popover>
  <button>Option 1</button>
  <button>Option 2</button>
  <!-- Tab key can leave the popover entirely -->
</div>

For modal-like popovers (where focus should stay trapped), you still need JavaScript focus trapping or the <dialog> element with showModal().

Auto-Focus: Not Automatic

<button popovertarget="search">Search</button>
<div id="search" popover>
  <input type="text" placeholder="Type here..." />
  <!-- Input doesn't auto-focus on popover open -->
</div>

Unlike <dialog>, popovers don't auto-focus the first focusable element. Use the toggle event:

searchPopover.addEventListener('toggle', (e) => {
  if (e.newState === 'open') {
    searchPopover.querySelector('input').focus();
  }
});

Events: beforetoggle vs toggle

beforetoggle fires before state change and is cancelable. toggle fires after and cannot be canceled.

popover.addEventListener('beforetoggle', (e) => {
  if (e.newState === 'closed' && hasUnsavedChanges()) {
    e.preventDefault(); // Prevent closing
    showConfirmDialog();
  }
});

The ToggleEvent includes oldState, newState, and source (the invoker element that triggered it).

showModal() and requestFullscreen() Dismiss Auto Popovers

Opening a modal dialog or entering fullscreen closes all auto popovers. This is in the spec:

document.getElementById('myPopover').showPopover();
// Popover visible

document.getElementById('myDialog').showModal();
// Popover now hidden!

If you need both, open the modal first, then show popovers from inside it.

Browser Support and the Polyfill Gap

Baseline support since April 2024 (Chrome 114+, Safari 17+, Firefox 125+). But older versions need the OddBird polyfill, which can't perfectly replicate top-layer behavior:

  • Polyfill uses high z-index, so stacking context issues return
  • Light dismiss behavior may differ slightly
  • Anchor positioning unsupported in polyfill

Feature detection:

const supportsPopover = HTMLElement.prototype.hasOwnProperty('popover');
if (!supportsPopover) {
  // Load polyfill or fallback
}

Key Takeaways

  • Popovers use the top layer, escaping z-index—but modals and popovers interact unexpectedly
  • popover="auto" only allows one at a time; nesting requires DOM relationship, invoker, or anchor
  • Nesting relationship is established at show time—order matters
  • No focus trapping by default; add it manually or use <dialog>
  • showModal() and fullscreen dismiss all auto popovers
  • Use beforetoggle for cancelable events, toggle for post-change actions

Advertisement

Related Insights

Explore related edge cases and patterns

CSS
Expert
The 16 Ways CSS Creates Stacking Contexts
8 min
CSS
Surface
CSS :has() — The Parent Selector That Changes Everything
6 min

Advertisement