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:
- Info icon gets
anchor-name: --specon each card (scoped per card; names are document-wide so use unique suffixes or contain within shadow DOM if collisions arise). - Tooltip uses
position-anchor: --specandposition-area: bottomwithposition-try-fallbacks: flip-block. - Visibility toggled with
:hoverand:focus-withinon the card — or:has(.spec-trigger:focus-visible)for keyboard paths (see our :has() guide). - Inside narrow card containers, a
@container (max-width: 280px)rule switches toposition-area: rightso 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 names —
anchor-nameis document-scoped; two--tooltipanchors create ambiguous bindings. Namespace per component instance. - Missing position declaration —
position-anchorwithoutposition: absoluteorfixedis ignored. - Overflow hidden ancestors — anchors do not escape clipping; a tooltip inside
overflow: hiddenstill 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
leftbreaks RTL; preferstart/endanchor sides. - Display:none measurement — toggling
display: noneprevents layout; usevisibilityoropacityfor off-screen sizing. - Stacking context surprises — high
z-indexon an ancestor can bury anchored tooltips; verify stacking alongside positioned triggers.
Production checklist
- Name anchors with unique, prefixed identifiers per component instance.
- Set
position: absoluteorfixedon the positioned dependent. - Declare
position-try-fallbacksfor viewport edge collision. - Test keyboard focus paths with
:focus-visibleand:has(). - Add
@containerrules 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-nameon the trigger andposition-anchoron the popover are the minimum pair.position-areaandposition-try-fallbacksreplace 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
- CSS container queries explained — flip tooltip placement by parent width
- CSS :has() selector explained — parent-conditional tooltip visibility
- CSS subgrid explained — aligned grids that anchors often overlay
- Responsive web design explained — viewport and component breakpoints