← Notes
9 May 2024 Browser APIs

The Popover API walks in and the JS bundle gets smaller

The Popover API became baseline in April 2024. Native tooltips, modals, dropdowns — with focus management, light-dismiss and ::backdrop. No JavaScript required for the basic case.

  • #html
  • #browser-api
  • #components
  • #no-js
  • #accessibility

The Popover API became Baseline in April 2024. The [popover] attribute gives you a floating layer with light-dismiss (click outside to close), top-layer rendering (above everything, including fixed elements and z-index stacks) and a ::backdrop pseudo-element. The browser handles the plumbing.

Browser support: Baseline April 2024. Chrome 114+, Firefox 125+, Safari 17+. MDN reference.

The minimum viable tooltip

<button popovertarget="my-tooltip" popovertargetaction="toggle">
  What does this button do?
</button>

<div id="my-tooltip" popover>
  It does the thing. You know the thing.
</div>
↓ Click — light-dismiss and top-layer rendering, no JS
It does the thing. No z-index: 9999. No click-outside handler. No JavaScript.

Click the button. The popover appears. Click outside. It disappears. No document.addEventListener. No click-outside detection. No z-index: 9999. No JavaScript.

A modal dialog without the boilerplate

<button popovertarget="confirm-dialog">Delete account</button>

<div id="confirm-dialog" popover="manual" role="dialog" aria-modal="true" aria-labelledby="confirm-heading">
  <h2 id="confirm-heading">Are you sure?</h2>
  <p>This cannot be undone. Your data will be gone. Your settings, gone. Your carefully curated playlists — also gone.</p>
  <button popovertarget="confirm-dialog" popovertargetaction="hide">Cancel</button>
  <button id="confirm-delete">Yes, delete everything</button>
</div>

popover="manual" disables light-dismiss — the modal only closes when explicitly dismissed.

Accessibility note: [popover] does NOT trap focus the way <dialog> does. For a true modal that prevents keyboard escape, use <dialog> with .showModal(). For non-modal popovers (tooltips, dropdowns, menus), the popover API is appropriate. Always add role="dialog" + aria-modal="true" for popover-based dialogs and ensure keyboard focus is managed manually if you need full trapping.

[popover] {
  border: none;
  border-radius: 8px;
  padding: 2rem;
  max-width: 420px;
  box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}

[popover]::backdrop {
  background: rgba(0, 0, 0, 0.4);
  backdrop-filter: blur(2px);
}

The @starting-style animation integration

[popover] {
  opacity: 1;
  transform: translateY(0) scale(1);
  transition:
    opacity 200ms ease,
    transform 200ms ease,
    overlay 200ms allow-discrete,
    display 200ms allow-discrete;
}

[popover]:not(:popover-open) {
  opacity: 0;
  transform: translateY(-8px) scale(0.97);
}

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: translateY(-8px) scale(0.97);
  }
}

The :popover-open pseudo-class selects the popover in its open state. @starting-style defines where the enter transition begins. Result: smooth fade-scale in, fade-scale out, with the element actually leaving the DOM at the end of the exit transition, not just hiding.

Where it falls short

  • Positioning. The popover appears centered in the viewport by default. For tooltips that need to appear next to their trigger, you need CSS Anchor Positioning or JavaScript to set style.top/left.
  • Focus trapping. [popover] doesn’t trap focus. Use <dialog> for that.
  • Nested popovers. Multiple popovers can stack, but auto-dismiss behavior gets complex with nested hierarchies. Test it.
  • Accessibility role. [popover] doesn’t imply an ARIA role. Add role="dialog" for dialogs, role="menu" for menus.

The JS you still write

// That's it.
document.getElementById('confirm-delete').addEventListener('click', async () => {
  await deleteAccount();
  document.getElementById('confirm-dialog').hidePopover();
});

The plumbing is handled. What’s left is the actual business logic — which is what the JavaScript should have been doing instead of managing focus traps.

The component library version of a modal is roughly 200–400 lines: focus trap, click-outside handler, scroll lock, keyboard handling, z-index management, portal rendering, animation state. The HTML-native version is three lines of markup, two CSS blocks and one line of JavaScript when you need to respond to the user’s actual decision.

That ratio has changed.