Guide

CSS anchor positioning explained

Harbor Commerce's product comparison grid showed spec tooltips on hover for dimensions, warranty terms, and shipping cutoffs. Each tooltip was absolutely positioned with JavaScript: getBoundingClientRect() on the trigger, manual offset math, a ResizeObserver to recompute on scroll, and a flip routine when the bubble clipped the viewport edge. The bundle shipped 180 lines of positioning code that broke on zoom, RTL layouts, and nested scroll containers. Engineers replaced it with CSS anchor positioning — a declarative model where one element names itself as an anchor and a positioned sibling references that anchor through position-anchor and anchor() inset functions. Tooltips now track their triggers through resize, scroll, and container queries without listeners. This guide explains anchor-name, position-area, the anchor() function, fallback and flip behavior, how anchor positioning pairs with container queries and the :has() selector, walks a Harbor Commerce refactor, provides a technique decision table, lists pitfalls, and ends with a production checklist alongside our responsive web design guide and CSS subgrid guide.

What anchor positioning solves

For decades, attaching a popover to a button meant choosing between fragile absolute positioning (which breaks when ancestors scroll) and JavaScript libraries that measure DOM geometry on every frame. Anchor positioning moves the relationship into CSS: the browser knows which element is the anchor and which positioned box should stay relative to it, including when either element moves because of layout reflow.

Typical use cases:

  • Tooltips and help bubbles anchored to icons or truncated labels.
  • Dropdown menus that open below a trigger but flip above when near the viewport bottom.
  • Contextual panels beside form fields without a portal to document.body.
  • Speech bubbles and callouts in marketing layouts where the arrow must track a photo hotspot.

Anchor positioning is complementary to the Popover API and popover attribute for focus management and top-layer promotion — you can combine them: Popover handles layer stacking and light dismiss; anchor positioning handles geometry.

Core properties

Naming the anchor

Any element can become an anchor by setting anchor-name. Names are identifiers, not selectors — conventionally prefixed with two dashes:

.spec-trigger {
  anchor-name: --product-spec;
}

An element may expose multiple anchor names if it serves several positioned dependents (e.g., a chart point with both a tooltip and a vertical guide line).

Binding the positioned element

The popover, tooltip, or menu references the anchor through position-anchor:

.spec-tooltip {
  position: absolute;
  position-anchor: --product-spec;
  bottom: anchor(top);
  left: anchor(center);
  translate: -50% 0;
  margin-bottom: 8px;
}

position: absolute (or fixed) is required. The positioned box's containing block is still determined by normal CSS rules, but inset values can reference anchor edges through the anchor() function.

The anchor() function

anchor(<anchor-side>) resolves to a length from the positioned element's containing block edge to the specified edge of the anchor element. Sides include physical edges (top, right, bottom, left) and logical edges (start, end, self-start, self-end) for RTL-aware layouts.

You can also use percentage forms like anchor(50%) along an axis, or anchor(center) as shorthand for the midpoint. Two-value forms set both axes: anchor(right bottom).

position-area shorthand

For common placements, position-area maps the positioned box to a region relative to the anchor — similar to grid-area but for overlay layout:

.menu-panel {
  position: absolute;
  position-anchor: --menu-button;
  position-area: bottom span-right;
  margin-top: 4px;
}

Keywords like top, bottom, left, right, center, and span-* variants describe which side of the anchor the element hugs and how it spans across the anchor's width or height.

Fallback and flip behavior

A positioned box can declare an ordered list of fallback positions. When the preferred placement overflows the scrollport or viewport, the browser tries the next option:

.spec-tooltip {
  position-area: bottom;
  position-try-fallbacks:
    flip-block,
    flip-inline,
    bottom span-left;
}

flip-block mirrors above/below; flip-inline mirrors left/right. Custom fallback sets can name explicit position-area values. This replaces hand-rolled JavaScript that measured window.innerHeight and toggled CSS classes.

Pair fallbacks with position-visibility (when supported) to hide the tooltip entirely if no non-overlapping placement exists — better than clipping half off-screen.

Harbor Commerce product tooltip refactor

Before: Each product card mounted a tooltip div as a sibling, positioned with top: triggerRect.bottom + 8 computed in a React effect. Scroll listeners on three nested containers re-ran the effect. RTL product pages needed a separate branch. Lighthouse flagged the layout thrashing.

After:

  1. Info icon gets anchor-name: --spec on each card (scoped per card; names are document-wide so use unique suffixes or contain within shadow DOM if collisions arise).
  2. Tooltip uses position-anchor: --spec and position-area: bottom with position-try-fallbacks: flip-block.
  3. Visibility toggled with :hover and :focus-within on the card — or :has(.spec-trigger:focus-visible) for keyboard paths (see our :has() guide).
  4. Inside narrow card containers, a @container (max-width: 280px) rule switches to position-area: right so tooltips open beside the icon instead of below.

JavaScript dropped from 180 lines to zero for geometry. Remaining script only handles analytics. Cumulative Layout Shift improved because tooltip dimensions are known before first paint when using visibility: hidden instead of display: none during measurement.

Technique decision table

Need Prefer Why
Tooltip beside a trigger, scroll-safe CSS anchor positioning Browser tracks anchor through reflow; no listeners
Modal dialog with focus trap Popover API + dialog element Top layer and inert backdrop; anchor not required
Dropdown in legacy browser matrix Floating UI / Popper.js Anchor positioning support still rolling out
Sticky header offset for anchored menu anchor() + scroll-driven or JS scroll offset Anchor edges move; may need position: fixed anchor
Chart crosshair following cursor JavaScript pointer events Anchor is element-based, not free-floating pointer
Component-responsive tooltip side Anchor + @container rules Flip placement based on card width, not viewport

Browser support and progressive enhancement

Anchor positioning landed in Chromium 125+ and is actively shipping in Firefox and Safari. Treat it as progressive enhancement:

@supports (anchor-name: --x) {
  .tooltip { position-anchor: --x; position-area: bottom; }
}

@supports not (anchor-name: --x) {
  .tooltip { /* fallback: static placement or Popper */ }
}

Feature detection via @supports is more reliable than user-agent sniffing. For critical flows (checkout error hints), keep a minimal JavaScript fallback until your analytics show > 95% support among target browsers.

Common pitfalls

  • Duplicate anchor namesanchor-name is document-scoped; two --tooltip anchors create ambiguous bindings. Namespace per component instance.
  • Missing position declarationposition-anchor without position: absolute or fixed is ignored.
  • Overflow hidden ancestors — anchors do not escape clipping; a tooltip inside overflow: hidden still clips. Use top-layer Popover or portal when necessary.
  • Assuming pointer tracking — anchors bind to elements, not mouse coordinates. Cursor-following UI still needs JS.
  • Ignoring logical properties — hard-coding left breaks RTL; prefer start/end anchor sides.
  • Display:none measurement — toggling display: none prevents layout; use visibility or opacity for off-screen sizing.
  • Stacking context surprises — high z-index on an ancestor can bury anchored tooltips; verify stacking alongside positioned triggers.

Production checklist

  • Name anchors with unique, prefixed identifiers per component instance.
  • Set position: absolute or fixed on the positioned dependent.
  • Declare position-try-fallbacks for viewport edge collision.
  • Test keyboard focus paths with :focus-visible and :has().
  • Add @container rules to flip placement in narrow parents.
  • Wrap rules in @supports (anchor-name: --x) with a JS fallback.
  • Verify RTL mirroring with logical anchor sides.
  • Check clipped ancestors; promote to Popover top layer if needed.
  • Measure CLS: avoid layout-forcing display toggles on show.
  • Document anchor names in component design specs for designer handoff.

Key takeaways

  • Anchor positioning links a positioned box to a named element through CSS, not JavaScript geometry.
  • anchor-name on the trigger and position-anchor on the popover are the minimum pair.
  • position-area and position-try-fallbacks replace manual viewport flip logic.
  • Combine with container queries and :has() for component-local, accessible tooltip behavior.
  • Ship with @supports fallbacks until browser coverage matches your audience.

Related reading