css.
css9 min read

CSS Anchor Positioning for Tooltips and Popovers: An Intro

CSS Anchor Positioning for Tooltips and Popovers: An Intro

The floating-UI problem nobody enjoyed solving

Why does something as visually simple as a tooltip demand two decades of workarounds on the web? A small thing that hovers next to a bigger thing. Tooltips, popovers, dropdown menus, autocomplete suggestions, date pickers, context menus, hover cards, toast anchors. They all share the same requirement. A floating element must attach itself to a reference element, follow it during scrolling, flip to a different side when the viewport clips it, and detach cleanly when the reference disappears. And for two decades, doing this correctly meant either a hand-rolled positioning script, a heavy JavaScript library like Popper or Floating UI, or a component framework that hid all of that machinery behind a wrapper.

Every solution I tried had trade-offs. Absolute-positioned CSS gave me no viewport awareness. Fixed positioning ignored the scroll parent. Portals into body fixed the stacking-context mess but broke accessibility trees. JavaScript observers were reliable but expensive during scroll. And none of the pure-CSS approaches could handle the truly hard case: an anchor that lives inside an overflow-hidden container, while the floating element must escape that container to render on top of everything else without being clipped.

CSS Anchor Positioning is the browser's answer to all of it. Instead of a script that reads getBoundingClientRect() on every animation frame, the layout engine itself learns the concept of "this element is anchored to that element." You give one element a name, tell another element which name it wants to attach to, and then use anchor-aware functions inside top, left, right, bottom, or inset-area to describe the geometry. The browser handles scrolling, resizing, overflow, and even fallback positioning without a single line of JavaScript.

What the spec actually gives you

You can learn this entire feature by memorizing three property names, and one of them is just a label you invent yourself. The first primitive is anchor-name, a property applied to the element that other things want to anchor to. It takes a dashed identifier, so a button that will host a tooltip might declare anchor-name: --submit-button. The name lives in the containing block's namespace, so you can reuse the same name in different parts of the page as long as scopes do not collide.

The second primitive is position-anchor, applied to the absolutely or fixed-positioned element that wants to attach itself. It names the anchor it is following. From that moment on, the browser knows there is a relationship between the two elements, and layout of the anchored element depends on the layout of the anchor element.

The third primitive, and the most powerful, is the anchor() function. It can be used inside any inset property (top, left, right, bottom, or the logical equivalents like inset-block-start) and returns a length that references a specific edge of the anchor element. Writing top: anchor(bottom) on a tooltip means "position my top edge at the bottom edge of my anchor." Combine two calls and you have full two-dimensional positioning derived entirely from the anchor's box.

There is also a higher-level shorthand called inset-area (renamed to position-area in later drafts) that lets you describe the placement in human terms rather than four separate inset properties. Values like top, bottom span-all, or start-end map to a 3-by-3 grid overlayed on the anchor. It's more ergonomic than the raw inset dance, and it composes cleanly with fallback positioning.

A tooltip in fewer lines than the JavaScript alternative

A friend of mine once shipped a 400-line React component whose only job was to place a menu next to a button, and the CSS-only replacement below is shorter than that component's imports section. The example below builds a tooltip that appears above a button, includes a small offset, and flips below the button when the top edge would clip out of the viewport. In classical JavaScript-driven libraries this is fifty to a hundred lines of code plus a runtime. In anchor-positioned CSS, it's fewer lines than a typical margin utility.

.help-button {
  anchor-name: --help-anchor;
}

.help-tooltip {
  position: absolute;
  position-anchor: --help-anchor;

  bottom: anchor(top);
  left: anchor(center);
  translate: -50% -8px;

  padding: 6px 10px;
  background: #111;
  color: #fff;
  border-radius: 6px;
  font-size: 12px;
  max-width: 240px;

  position-try-fallbacks:
    flip-block,
    flip-inline,
    flip-block flip-inline;
}

The translate line handles the small gap and the horizontal centering. The position-try-fallbacks line is where the magic sits. When the tooltip in its default placement would collide with the viewport edge, the browser tries each fallback in order: flip to below the button, mirror the horizontal alignment, or do both. There is no listener, no observer, no rerender. The tooltip simply appears where it fits.

Why this pairs so well with the Popover API

Anchor positioning grew up alongside another browser addition that solves a different half of the same problem. The Popover API gives HTML a native way to declare an element as top-layer content that opens over the rest of the page, dismisses on outside click or Escape, manages focus, and lives in the top layer so it is never clipped by an ancestor's overflow rules. It does not tell you where to render the popover. That is the caller's problem.

Anchor positioning fills exactly that gap. A <div popover> element can carry position-anchor: --menu-button, use anchor() in its insets, and inherit both the top-layer escape hatch and the anchor-driven placement. Together they replace nearly every custom popover component I've written in React, Vue, Svelte, or vanilla JavaScript. Combobox menus, share sheets, filter dropdowns, action menus in tables: the browser now natively covers the layout, the stacking context, the dismissal behavior, and the accessibility. What was once a five-hundred-line component becomes a <button> with popovertarget and a <div popover> with an anchor.

The pairing also unlocks something previously very hard: menus that appear inside virtualized lists. If your table row is inside overflow: auto and gets recycled by a virtual scroller, a popover attached to a cell button can still render above the table without being clipped, because top-layer rendering ignores overflow, and anchor positioning still tracks the button as long as it is in the DOM. When the row scrolls away and the button unmounts, the popover automatically dismisses.

Fallbacks are where the design pressure lives

Real interfaces rarely allow a floating element to sit in its ideal position. A tooltip in a table header wants to render above, but the table might be flush against the viewport top. A menu might want to open below its trigger, but there are only forty pixels of space left. The fallback system is what turns anchor positioning from a nice demo into a production-grade feature.

Two mechanisms cooperate here. position-try-fallbacks accepts either named fallback macros like flip-block and flip-inline, or references to named @position-try at-rules that give you full control over insets, size, and even position-area on retry. The browser evaluates the primary placement first, checks whether the anchored element overflows its overflow area, and if it does, walks the list of fallbacks in order until one fits. If none fits, the last one is used anyway.

Named fallbacks let you handle asymmetric cases that the built-in macros cannot. Imagine a hover card that prefers to appear to the right of its anchor with fifty pixels of horizontal offset, but on narrow screens should switch to a fullscreen bottom sheet. A single @position-try --bottom-sheet block with inset: auto 0 0 0 and its own sizing rules can express that fallback declaratively. Everything else, including the transition between placements, is up to CSS. There's no state to synchronize between JavaScript and layout because there is no JavaScript in the loop.

What anchor positioning is not

It's worth setting expectations. Anchor positioning does not create the anchor relationship for you: both elements must exist in the DOM at the same time, and the anchor must be an actual element with layout. You cannot anchor to a virtual coordinate. You cannot anchor to the mouse cursor without a small helper element that follows it. You cannot use it for dynamic connectors like SVG arrows between two arbitrary nodes; for those you still need geometry math in JavaScript.

It also does not remove the need to think about accessibility. A popover that appears attached to a button still needs aria-describedby for a tooltip role, or the right aria-expanded and aria-controls pairing for a menu. The Popover API handles focus trapping and dismissal, but semantic wiring remains the author's responsibility. If a design uses anchor positioning to build something that behaves like a dialog, it must actually be a dialog, not a <div popover> styled to look like one. The browser will not infer role from geometry.

Finally, animation is more subtle than it looks. Because the anchored element's position is computed from the anchor's box, animating that position with a plain CSS transition can feel jittery when the anchor itself is being animated. The @starting-style rule and the display: none to display: block transition support that shipped in modern browsers together with anchor positioning are the correct tools here. They allow the popover to animate in from a nearby position rather than snapping.

Browser support and progressive enhancement

Anchor positioning shipped first in Chromium in late 2024 and has been rolling out through the other engines since. As of mid-2026 it's a first-class feature in Chromium-based browsers and Safari, with Firefox tracking the spec closely and shipping partial implementations behind flags. The W3C CSS Anchor Positioning specification is the authoritative reference for what's stable and what's still being debated, and MDN mirrors it with runnable examples and a browser-support matrix.

For production, the recommended approach is progressive enhancement. Feature-detect with @supports (anchor-name: --x) and provide a CSS fallback that uses classical absolute positioning inside a positioning container. For teams already using Floating UI or a similar library, the migration path is not all-or-nothing: keep the library for interactions in browsers that lack the feature, and let the CSS take over automatically where it can. Because the two systems produce visually identical output when both are configured correctly, users won't notice the crossover.

The bigger shift

Anchor positioning is part of a larger pattern in modern web platform work: taking capabilities that used to require imperative JavaScript at animation-frame cadence and moving them into declarative CSS that runs inside the layout engine. Container queries did this for responsive design. View transitions did it for animated state changes. The Popover API did it for dismissable overlays. Anchor positioning completes the picture for floating UI.

The practical effect on codebases is measurable. Component libraries that once bundled a positioning engine can drop it. Design systems that once maintained a wrapper around a third-party library can lean on primitives that ship with the browser. And the interactive latency of interfaces improves because the browser no longer has to shuttle geometry between JavaScript and layout on every scroll event.

For anyone building interfaces on the web today, learning the three-primitive core of anchor positioning pays off quickly. It took me an afternoon, and it retired a category of code that had quietly weighed down my frontends for a very long time.