Popover API + CSS Anchor Positioning: Tooltips and Dropdowns Without a Library
Build accessible tooltips and dropdown menus with the native Popover API and CSS anchor positioning - no Floating UI required - and wire them into React 19.
For most of the last decade, putting a tooltip next to a button meant installing a positioning library. Popper.js, then Floating UI, plus a z-index scheme, scroll listeners, resize observers and flip logic - all to answer one question: where should this box go, and what happens when it hits the edge of the screen? In 2026 the platform finally answers that question itself. The Popover API (Baseline since 2024) handles showing, hiding, stacking and light dismiss. CSS anchor positioning, which reached all three major engines through the Interop effort, handles placement and edge flipping. Together they replace a surprising amount of JavaScript.
This post builds a dropdown menu and a tooltip with zero positioning JavaScript, then shows how to use both from React 19, which ships first-class support for the popover attributes and events.
The Popover API in sixty seconds
A popover is any element with the popover attribute. A button points at it with popovertarget, and the browser wires up the rest - no click handlers, no state:
<button popovertarget="filters" id="filters-btn">
Filters
</button>
<div id="filters" popover>
<label><input type="checkbox" /> In stock only</label>
<label><input type="checkbox" /> On sale</label>
</div>That one attribute buys you a lot:
- Top layer rendering. The popover paints above everything, regardless of
z-indexoroverflow: hiddenancestors. No portal needed. - Light dismiss. The default
popover="auto"closes on Escape or on a click outside.popover="manual"opts out for toast-like UI. - Toggle without JS. The same button opens and closes it.
popovertargetaction="show"or"hide"pins the direction if you want separate buttons. - Styling hooks.
:popover-openmatches while it is open, and::backdropstyles the layer behind it.
When you do need JavaScript, the element exposes showPopover(), hidePopover() and togglePopover(), and fires beforetoggle and toggle events whose newState property is either "open" or "closed". That is the whole API surface.
Anchoring it to the button
Out of the box a popover appears centered in the viewport - the UA stylesheet gives it position: fixed; inset: 0; margin: auto. Fine for a dialog-ish panel, wrong for a dropdown. CSS anchor positioning fixes that with two properties: the trigger declares an anchor-name, and the popover tethers to it with position-anchor plus a placement via position-area:
#filters-btn {
anchor-name: --filters;
}
#filters {
position-anchor: --filters;
position-area: block-end span-inline-end;
/* reset the UA centering styles */
margin: 0;
inset: auto;
margin-block-start: 6px; /* gap below the button */
}position-area places the popover on an imaginary 3x3 grid around the anchor. block-end means the row below the button; span-inline-end aligns the popover with the button's start edge and lets it grow toward the end. For a classic centered tooltip you would use position-area: block-start together with justify-self: anchor-center.
Two more tools are worth knowing. The anchor() function gives coordinate-level control when the grid is not enough - for example top: anchor(bottom) pins the popover's top to the button's bottom. And anchor-size() lets a dropdown match its trigger's width, a classic select-menu requirement that used to need a ResizeObserver:
#filters {
min-width: anchor-size(width);
}Staying on screen: position-try-fallbacks
Flipping near the viewport edge is the reason positioning libraries exist. Declaratively, it is one line: list the fallback placements the browser may try when the preferred one overflows.
#filters {
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline,
flip-block flip-inline;
}If the menu would clip below the fold, the browser flips it above the button; if it would clip at the inline edge, it mirrors horizontally; the combined keyword covers corners. The browser re-evaluates on scroll and resize for free. For placements that need more than a mirror image - say, different offsets when flipped - define a named fallback with @position-try:
@position-try --above {
position-area: block-start span-inline-end;
margin-block-start: 0;
margin-block-end: 6px;
}
#filters {
position-try-fallbacks: --above;
}Wiring it into React 19
React 19 supports the popover attributes as regular props - popover, popoverTarget, popoverTargetAction - and exposes the toggle events as onToggle and onBeforeToggle. Because anchor-name must be unique per instance, generate it from useId (stripping the colons, which are not valid in CSS identifiers):
import { useId } from 'react';
function ActionsMenu({ label, onOpen, children }) {
const id = 'menu-' + useId().replace(/:/g, '');
const anchorName = '--' + id;
return (
<>
<button popoverTarget={id} style={{ anchorName }}>
{label}
</button>
<div
id={id}
popover="auto"
className="menu"
style={{ positionAnchor: anchorName }}
onToggle={(e) => {
if (e.newState === 'open' && onOpen) onOpen();
}}
>
{children}
</div>
</>
);
}Note what is not here: no useState for open/closed, no createPortal, no outside-click effect, no positioning hook. The onToggle handler receives the native ToggleEvent, so e.newState tells you which way it went - handy for lazy-loading menu contents or analytics. The inline style object works because supporting browsers expose the camelCased anchorName and positionAnchor properties on CSSStyleDeclaration.
The shared stylesheet stays tiny:
.menu {
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block;
margin: 0;
inset: auto;
margin-block-start: 4px;
border: 1px solid #d0d0d0;
border-radius: 8px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}One forward-looking note: when a popover is opened via popovertarget, newer browsers treat the invoking button as an implicit anchor, which lets you drop the explicit names entirely. Support for that shorthand is still uneven, so explicit anchor-name remains the portable choice today.
Progressive enhancement and feature detection
The two features degrade differently, and that matters for your rollout plan. The Popover API is Baseline 2024 and safe to rely on for evergreen-browser audiences; if you must reach older ones, the @oddbird/popover-polyfill package patches it, and you can detect support with a one-liner:
const supportsPopover = 'popover' in HTMLElement.prototype;
const supportsAnchor = CSS.supports('anchor-name', '--a');Anchor positioning is newer. The good news is that its failure mode is gentle: in a non-supporting browser the popover still opens, still light-dismisses, still sits in the top layer - it just appears centered in the viewport instead of attached to the button. For a filter panel that is often acceptable. Where it is not, scope the anchored layout inside @supports and provide a simpler fallback outside it:
@supports not (anchor-name: --a) {
.menu {
/* fallback: centered panel with a dimmed backdrop */
margin: auto;
inset: 0;
}
.menu::backdrop {
background: rgba(0, 0, 0, 0.3);
}
}When you still want Floating UI
This is not a funeral for positioning libraries - yet. Reach for Floating UI when you need any of the following:
- Detached or virtual anchors, like a context menu at the cursor's coordinates - CSS anchors must be real elements.
- Guaranteed identical behavior in older browsers, where the CSS fallback story above is not acceptable.
- Middleware-style logic - arrow elements that track the flip, size clamping with custom math, or placement decisions driven by app state.
For the everyday cases - tooltips, dropdown menus, select-like panels, hover cards - the platform now does the job with a handful of declarations. Ship the native version, keep the bundle bytes, and let the browser handle the geometry.