Every project I have shipped has eventually grown a dropdown, and every one of them used to drag in a positioning library to answer one question: where does this box go? As of 2026 the browser answers it. The Popover API handles the layer, the dismissal and the focus; CSS anchor positioning handles the geometry. The part the tutorials skip is that the two features have default styles that fight each other, and a handful of conditions that silently make anchoring do nothing at all. Those are what this post is mostly about.
What each half actually gives you
The Popover API is HTML attributes. An element with popover starts
hidden in the top layer; a button with popovertarget toggles it. For free you get:
light dismiss on outside click, Esc to close, focus returned to the trigger, no
z-index war because the top layer sits above everything, and — the detail that
matters here — an implicit anchor linking popover to invoker.
CSS anchor positioning is the geometry half. One element declares
anchor-name, another declares position-anchor and a placement, and the
layout engine does the maths it already knows how to do — no getBoundingClientRect(),
no scroll listener, no reflow-on-resize loop.
Because a popover already has an implicit anchor and is already position: fixed, the
combined version is short:
<button popovertarget="menu" aria-haspopup="menu">Options</button>
<div id="menu" popover role="menu">…</div>
@supports (anchor-name: --x) {
#menu {
inset: auto;
margin: unset;
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block;
margin-block-start: 6px;
}
}
That is the whole dropdown. No anchor-name needed, because the invoker is the implicit
anchor. If the element is not a popover, add anchor-name: --trigger on the
button and position: absolute; position-anchor: --trigger; on the box.
Placement: position-area first, anchor() when you need precision
position-area divides the space around the anchor into a 3×3 grid and lets you
name a cell with one or two keywords. Physical keywords work (top right), but logical
ones — block-end, inline-start — are the better habit: they follow writing
mode, so an RTL layout mirrors itself for free. span-* keywords widen the cell across
two columns or rows, which is how you get "below the button, aligned to its start edge, free to grow
outward".
The lower-level tool is the anchor() function, valid inside inset properties:
top: anchor(--trigger bottom); left: anchor(--trigger left);. Use it when you need an
offset the grid cannot express, and anchor-size() when you want the popover to match
the trigger's width (a native <select>-style dropdown:
min-width: anchor-size(width)). One caveat worth knowing:
position-area creates a new containing block for the positioned element, so percentage
padding and margin resolve against that box, not the one you were expecting.
Six things that silently break it
- The popover's own default styles. The UA stylesheet sets
inset: 0andmargin: autoon[popover]to centre it. Both fight your placement, andmargin: autoin particular produces the classic "why is it floating in the middle of nowhere". Resetinset: auto; margin: unset;— but do it inside@supports (anchor-name: --x), or browsers without anchoring lose their sensible centred fallback. - The positioned element is still static. Anchoring only applies to absolutely positioned elements. Popovers are already fixed; ordinary divs are not.
- The name is out of scope.
anchor-namemust be declared in the same shadow or document tree as the rule referring to it. Web-component boundaries break anchoring in a way that looks like a typo. - The anchor is hidden. If the anchor is
display: nonethe association drops, and the box positions itself against its nearest positioned ancestor instead — usually the page. - A clipping ancestor. Absolutely positioned boxes still get clipped by
overflow: hidden,clip-pathor paint containment on an ancestor. This is precisely why popovers are worth using: the top layer escapes all of it. - Scrolling. An anchored element tracks its anchor, so it stays on screen after the
anchor has scrolled away — visually detached. Add
position-visibility: anchors-visibleto hide it once the anchor is completely out of view (completely is literal: a visible sliver keeps it shown).
Fallbacks, and the arrow problem
position-try-fallbacks: flip-block is usually all a tooltip needs: try below, and if it
overflows, try above. You can list several options, including custom ones defined with
@position-try, and the browser walks them in order. Note the stickiness rule: once an
option succeeds it is kept until it overflows again, even if an earlier, nicer option becomes
available. That is deliberate — it stops jitter — but it does mean the position you see is not always
the first one that fits.
The follow-on problem is the little arrow: flip the tooltip and the arrow now points the wrong way.
The newer answer is anchored container queries — set
container-type: anchored on the positioned element and style its descendants inside
@container anchored(fallback: flip-block). Since container queries can only style
descendants, build the arrow on a child's pseudo-element rather than the tooltip's own. Where that is
not yet available, the honest fallback is a symmetric popover with no arrow at all.
Three popover stacks, and accessibility
popover="auto" (the default) is one-at-a-time with light dismiss and Esc.
popover="manual" does not dismiss itself at all — you own the toggling; right for toasts
and docked panels. popover="hint" is a second, subordinate stack for tooltips: showing a
hint does not close an open auto menu, though it closes unrelated hints. Support for
hint lags auto/manual, so treat it as an enhancement rather
than a foundation.
The platform gives you focus return and Esc; it does not give you semantics. Add them:
aria-haspopup="menu" on the trigger, role="menu" plus
aria-labelledby on the popover, and role="menuitem" on
every item (a role on the container alone is not enough). For a genuine menu you still write
the arrow-key roving-focus handler yourself — that is the one piece a library was actually earning its
bundle size for. For tooltips, wire aria-describedby from the trigger to the hint.
Animating in and out needs two extra pieces, because popovers toggle display:
transition-behavior: allow-discrete (or transition: … allow-discrete) plus a
@starting-style block for the entry values, and overlay in the transition
list so the element stays in the top layer while it fades out.
Support in 2026, and what to do about older browsers
Anchor positioning: Chrome and Edge 125+, Firefox 147+, Safari 26 on desktop and iOS — newly Baseline
as of January 2026. The Popover API is the older, safer half, having landed across all three engines
during 2023–2024. So the realistic posture today is: popovers unconditionally, anchoring as a
progressive enhancement behind @supports, with a centred or statically placed fallback.
If you need the geometry everywhere now, OddBird ships both a popover polyfill and an anchor
positioning polyfill (the latter working back to genuinely old versions). One trap: the anchor polyfill
does not create the implicit anchor for popover invokers, so under the polyfill you must give
the trigger an explicit anchor-name and reference it. Feature detect popovers with
'popover' in HTMLElement.prototype and load the polyfill conditionally.
What convinced me was the deletion: the hover listeners, the resize handler, the flip logic, the
z-index constants — all of it replaced by four declarations that the layout engine
evaluates for free. It is the same trade as moving page transitions into
the View Transitions API or navigation speculation into
Speculation Rules: less of your code doing work the browser was
always better placed to do.