Vivian Voss

The Native Popover That Positions Itself

css html javascript web

Stack Patterns ■ Episode 10

Every frontend team has written this code: a tooltip that appears on click, disappears when you click elsewhere, stays above the z-index of that one modal someone set to 9999, and kindly repositions itself when it hits the viewport edge. The result is typically Floating UI (35 KB), three event listeners, a resize observer, and a quiet prayer.

The browser now does all of it. Natively. Zero kilobytes of JavaScript. One does rather appreciate a specification that ships with fewer bugs than most npm packages.

The Popover API

Baseline in every browser since April 2025. One attribute:

<button popovertarget="tip">Settings</button>
<div id="tip" popover>Saved automatically.</div>

That is the entire JavaScript: none. The browser provides the top layer (no z-index required, the element renders above everything including that modal at 9999), light dismiss (click outside to close), keyboard handling (Escape), and focus management. For free. Try it:

This is a native popover. No JavaScript. Click outside or press Escape to close.

No addEventListener. No classList.toggle. No aria-expanded management. No state library to track whether a rectangle is visible. The HTML attribute popovertarget connects the button to the popover by ID. The browser handles the rest. One button, one div, zero lines of script.

Styling with :popover-open

The :popover-open pseudo-class tells you whether the popover is currently visible. The CSS equivalent of peeking through the curtain:

[popover] {
  opacity: 0;
  transition: opacity 0.2s, display 0.2s;
  transition-behavior: allow-discrete;
}
[popover]:popover-open {
  opacity: 1;
}

The transition-behavior: allow-discrete property is the genuinely clever bit. Normally, you cannot animate display: none to display: block because display is a discrete property, not an interpolatable one. The browser does not know what "halfway between none and block" looks like. allow-discrete tells the browser to wait until the transition completes before switching to display: none. The popover fades out before disappearing. No setTimeout. No animation callback. CSS handles the timing. Try the animated version:

Fades in. Fades out. No JavaScript. transition-behavior: allow-discrete does the work.

Three Modes

The Popover API has three modes, each with different behaviour:

  • popover (or popover="auto"): light dismiss (click outside closes it), only one auto popover open at a time. The sensible default for menus and tooltips
  • popover="manual": no light dismiss, stays open until explicitly closed. For persistent notifications, toasts, or anything the user should not accidentally dismiss by clicking elsewhere
  • popover="hint": designed for hover-triggered tooltips. Light dismiss, but does not close other auto popovers. The newest mode, available in Chrome and landing in other browsers

Auto mode. Click outside or press Escape to close. Only one open at a time.

Manual mode. Click outside does nothing.

CSS Anchor Positioning

The Popover API solves the "show and hide" problem. But it does not solve the "where does it appear" problem. A popover renders in the centre of the viewport by default. For a tooltip, you want it next to the trigger. For a dropdown menu, you want it below the button. For a date picker, you want it below the input, unless the input is at the bottom of the viewport, in which case you want it above.

This is the positioning problem, and it is the reason Popper.js (28 KB) and Tippy.js (22 KB) exist. They measure the trigger element, calculate available space in every direction, choose the best position, and recalculate on scroll, resize, and ancestor layout changes. The code is well-written. It also should not need to exist.

CSS Anchor Positioning (Chrome 125+, Safari 26+, Firefox behind a flag) solves this in CSS:

.trigger {
  anchor-name: --btn;
}

[popover] {
  position-anchor: --btn;
  top: anchor(bottom);
  left: anchor(left);
  position-try-fallbacks: flip-block, flip-inline;
}

anchor-name registers the trigger as an anchor. position-anchor ties the popover to it. anchor(bottom) places the popover's top edge at the trigger's bottom edge. And position-try-fallbacks is the part that earns its keep: if the popover does not fit below, flip it above (flip-block). If not above, try the side (flip-inline). The browser measures, decides, repositions. No scroll listener. No IntersectionObserver. No "getBoundingClientRect and hope for the best."

For custom fallback strategies beyond simple flipping, the @position-try at-rule lets you define named alternatives:

@position-try --above {
  bottom: anchor(top);
  top: auto;
  left: anchor(center);
}

[popover] {
  position-try-fallbacks: --above;
}
What You Ship Floating UI 35 KB Popper.js 28 KB Tippy.js 22 KB Native 0 KB Popover API + CSS Anchor Positioning Popover API: Baseline since April 2025. All browsers. Anchor Positioning: Chrome 125+, Safari 26+. Firefox behind flag.

The Point

Floating UI: 35 KB. Popper.js: 28 KB. Tippy.js: 22 KB. The native equivalent: zero kilobytes and a few lines of CSS. The popover half works today, in every browser, right now. The positioning half is landing: Chrome and Safari ship it, Firefox is behind a flag but closing in.

The buttons above this paragraph are not demos rendered by a JavaScript framework. They are native HTML with the popover attribute. The browser handles visibility, z-index, light dismiss, keyboard navigation, and focus management. The CSS handles animation. The developer handles nothing, which is, when one thinks about it, rather the ideal amount of responsibility for showing and hiding a rectangle.

One rather suspects the library authors saw this coming.

The Popover API: Baseline since April 2025. One attribute. Zero JavaScript. Top layer, light dismiss, keyboard handling, focus management. CSS Anchor Positioning: tie any element to any other element. Automatic viewport flipping. Zero scroll listeners. The browser now does what 35 KB of library code used to do. Natively.