Popover API + anchor positioning: dropdowns and tooltips without a library

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.

position-area grid around the anchor block-start block-start center block-start end anchor (button) popover placed here block-end span-inline-end no room flip-block tries block-start instead keeps the winning option until it overflows again @container anchored( fallback: flip-block) → move the arrow silent failures: [popover] inset:0 + margin:auto · anchor-name in another tree · anchor display:none not position:absolute/fixed · clipping ancestor · unsupported browser (feature-detect, then polyfill)

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

  1. The popover's own default styles. The UA stylesheet sets inset: 0 and margin: auto on [popover] to centre it. Both fight your placement, and margin: auto in particular produces the classic "why is it floating in the middle of nowhere". Reset inset: auto; margin: unset; — but do it inside @supports (anchor-name: --x), or browsers without anchoring lose their sensible centred fallback.
  2. The positioned element is still static. Anchoring only applies to absolutely positioned elements. Popovers are already fixed; ordinary divs are not.
  3. The name is out of scope. anchor-name must 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.
  4. The anchor is hidden. If the anchor is display: none the association drops, and the box positions itself against its nearest positioned ancestor instead — usually the page.
  5. A clipping ancestor. Absolutely positioned boxes still get clipped by overflow: hidden, clip-path or paint containment on an ancestor. This is precisely why popovers are worth using: the top layer escapes all of it.
  6. Scrolling. An anchored element tracks its anchor, so it stays on screen after the anchor has scrolled away — visually detached. Add position-visibility: anchors-visible to 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.

Related
→ View Transitions API for multi-page apps → Speculation Rules: instant navigation → Object pooling and GC stutter → Portfolio & projects
Can I replace Popper.js or Floating UI with CSS anchor positioning?

For dropdowns, menus and tooltips tethered to a button that should flip when space runs out — yes. Anchor positioning plus the Popover API covers placement, viewport flipping, top-layer stacking, light dismiss, Esc and focus return with no JavaScript. You still write code for arrow-key roving focus inside a menu, for virtual elements with no DOM node, and for browsers older than the 2026 Baseline.

Why is my anchor-positioned popover in the wrong place?

Nine times out of ten it is the UA stylesheet: [popover] gets inset: 0 and margin: auto, which centres it and overrides your placement. Reset inset: auto; margin: unset; inside @supports (anchor-name: --x). Other causes: the anchor-name lives in a different shadow tree, the anchor is display: none, or the positioned element is not absolute/fixed.

Which browsers support CSS anchor positioning in 2026?

Chrome and Edge 125+, Firefox 147+, Safari 26 (desktop and iOS); it became newly Baseline in January 2026. The Popover API is more widely available, having shipped in all three engines in 2023–2024. OddBird's polyfill extends anchoring to much older browsers but does not create the implicit anchor for popover invokers.

What is the difference between popover auto, manual and hint?

auto is the default: one per stack, light dismiss, Esc closes, and it closes unrelated auto popovers. manual never self-closes, so you toggle it — good for toasts and persistent panels. hint is a subordinate stack for tooltips: it does not close an open auto menu but does close other hints. hint support is narrower, so treat it as an enhancement.

How do I flip a tooltip when it does not fit on screen?

Use position-try-fallbacks: flip-block, or list custom @position-try options. The browser tries them in order and keeps the winning one until it overflows again. To reposition an arrow after a flip, set container-type: anchored and style descendants inside @container anchored(fallback: flip-block). To hide the element when its anchor scrolls out of sight, add position-visibility: anchors-visible.