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(orpopover="auto"): light dismiss (click outside closes it), only one auto popover open at a time. The sensible default for menus and tooltipspopover="manual": no light dismiss, stays open until explicitly closed. For persistent notifications, toasts, or anything the user should not accidentally dismiss by clicking elsewherepopover="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;
}
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.