WSS
Web Specification Studio Home
On this page
BlogCSSPerformancePublished

CSS Anchor Positioning: How Resolution, Scroll, and Fallbacks Actually Work

What happens when multiple anchors, multiple scroll containers, and position-try-fallbacks interact — the mechanics behind anchor-name, position-anchor, anchor(), and position-area that the examples don't show.

This covers how the CSS Anchor Positioning Module Level 1 resolves an anchor reference, tracks scroll, and applies fallback positions - the mechanics behind anchor-name, position-anchor, anchor(), position-area, and position-try-fallbacks once more than one anchor, one scroll container, or one fallback option is involved. Each section names the spec concept it documents and links the behavior to code.

What is CSS Anchor Positioning?

CSS Anchor Positioning is an API that allows developers to tether a positioned element (like a tooltip, popover, or menu) to one or more anchor elements on the page. Instead of relying on JavaScript to calculate coordinates and update them on scroll, Anchor Positioning lets you bind elements together declaratively in CSS, automatically handling scroll tracking and fallback positions when the element nears the edge of the viewport.

Acceptable anchor elements

Not every element with a matching anchor-name is eligible to be a target. The spec defines a test, the acceptable anchor element algorithm, that a candidate anchor has to pass for a given positioned element. The core requirement is that the anchor must be laid out strictly before the positioned element:

Relationship between the two elementsAcceptability rule
Same containing blockThe anchor is in a lower top layer, or (if both are in the same top layer) the anchor is not itself absolutely positioned, or it occurs earlier in flat-tree order
Different containing blocksThe element generating the anchor’s containing block must itself be an acceptable anchor element for the positioned element - the check recurses up the containing-block chain

A third condition applies to content-visibility: hidden subtrees: if the anchor is inside another element’s skipped contents, the positioned element must be inside that same skipped subtree, or the anchor does not qualify at all.

“Laid out strictly before” is a layout-order condition, not a paint-order or DOM-order condition. This distinction matters for two position: fixed elements: fixed-position elements can be laid out in a different order than they are painted, once stacking contexts are involved, so a positioned element can fail to acquire an anchor even when the anchor appears visually behind it and earlier in the markup. If an anchor relationship between two fixed-position elements does not resolve, check layout order rather than assuming a naming or scoping error.

Nested popovers are the other case where this table applies directly. A popover is promoted to the top layer, which changes its effective containing block to the viewport. A popover nested inside another popover, anchoring to an element outside the top layer, has to satisfy the different-containing-blocks branch of the table, and that ancestor’s containing block has to independently qualify. When this chain breaks, the anchor reference resolves to nothing and the positioned element falls back to its normal static position, generally at the viewport origin.

Resolving duplicate anchor names

An anchor-name value does not need to be unique across the document. The spec defines the resolution order for a positioned element that can see multiple elements sharing the same anchor name:

If multiple elements share an anchor name and are all visible to a given positioned box, the target anchor element will be the nearest ancestor (if one exists) or else the last one in DOM order.

The ancestor case is the common one and behaves as expected. The DOM-order case is the one that applies to sibling instances of a repeated component, and it resolves to the last matching element in tree order, not the first:

<ul>
  <li>
    <button class="trigger">Row 1</button>
    <div class="menu">…</div>
  </li>
  <li>
    <button class="trigger">Row 2</button>
    <div class="menu">…</div>
  </li>
  <li>
    <button class="trigger">Row 3</button>
    <div class="menu">…</div>
  </li>
</ul>
.trigger { anchor-name: --row-trigger; }
.menu {
  position: absolute;
  position-anchor: --row-trigger;
  position-area: bottom span-inline-end;
}

None of the .menu elements is a descendant of a .trigger, so the ancestor case never applies, and every .menu resolves --row-trigger to the last .trigger in the document - row 3’s trigger. All three menus anchor to the same element.

The anchor-scope property limits an anchor name’s visibility to a subtree, which is the mechanism for scoping a repeated anchor name to its own instance:

li {
  anchor-name: --row-trigger;
  anchor-scope: --row-trigger;
}
.menu {
  position: absolute;
  position-anchor: --row-trigger;
  position-area: bottom span-inline-end;
}

With anchor-scope: --row-trigger set on the <li>, the name is only visible to that <li>’s own descendants, so each .menu resolves to the trigger inside its own list item. Any component that reuses the same anchor name across sibling instances needs anchor-scope on the repeating container, or every instance binds to the same, final anchor.

Failure behavior of anchor()

An anchor() reference is only resolvable if it is applied to an absolutely positioned box, uses a side keyword valid for the axis of the inset property it’s in, and has a target anchor element that exists and is acceptable. When any of those conditions is false, the spec defines the fallback behavior:

If any of these conditions are false, the anchor() function computes to its specified fallback value. If no fallback value is specified, it makes the declaration referencing it invalid at computed-value time.

Invalid at computed-value time means the declaration is dropped entirely, and the property reverts to its initial or inherited value - not to some neutral positioning default. If top: anchor(--trigger bottom); is written without a third argument and --trigger is later removed from the DOM (for example, during an unmount that happens before the positioned element’s own exit transition completes), top reverts to auto, mixed in with whichever other inset properties still resolve. No error is raised.

Supplying the fallback argument keeps the declaration valid regardless of anchor lifetime:

.tooltip {
  position: fixed;
  top: anchor(--trigger bottom, 50%);
  left: anchor(--trigger left, 50%);
}

This is most relevant for anchors whose lifetime is shorter than the positioned element’s - a trigger that unmounts before its tooltip finishes an exit animation, for instance.

Scroll tracking and the remembered scroll offset

Resolving an anchor() reference against an element in a different scroll container is a layout operation, and layout cannot run on the compositor thread during scrolling. The spec’s mechanism for handling this, described in §3.3, is the remembered scroll offset: an anchor reference’s scroll contribution is captured at specific moments, called anchor recalculation points, rather than tracked continuously.

An anchor recalculation point occurs when the positioned element starts generating boxes (transitioning from display: none) or when its fallback position changes. Between recalculation points, tracking differs by anchor role:

Anchor roleScroll tracking
Default anchor (set via position-anchor, used by position-area, anchor-center, or an anchor() call with no name argument)Live - position updates continuously as its scroll container scrolls
Named anchor referenced explicitly via anchor(--name side), where --name is not the default anchorFrozen at the scroll offset recorded at the last anchor recalculation point

A construction that references more than one anchor by name, where only one of them is the default anchor, will only track scrolling live for that one anchor. The others keep using their last recorded offset until the positioned element re-displays or changes fallback:

#bar-1 { anchor-name: --anchor-1; }
#bar-2 { anchor-name: --anchor-2; }
#bar-3 { anchor-name: --anchor-3; }

.threshold-line {
  position: fixed;
  left: anchor(--chart left);
  right: anchor(--chart right);
  bottom: max(
    anchor(--anchor-1 top),
    anchor(--anchor-2 top),
    anchor(--anchor-3 top)
  );
}

If the three bars sit inside a scrollable panel, scrolling that panel moves the bars but does not move .threshold-line, because none of --anchor-1, --anchor-2, or --anchor-3 is this element’s default anchor. To get continuous scroll tracking for one of several referenced anchors, set it as the default anchor via position-anchor and reference the remainder only for secondary comparisons, with the expectation that those secondary references lag during a scroll until the next recalculation point.

The Position Fallback Origin

Properties set inside a @position-try rule apply through a dedicated cascade origin, the Position Fallback Origin, positioned between the Author origin and the Animation origin. Two rules follow directly from that placement.

!important is invalid inside a @position-try block:

It is invalid to use !important on the properties in the declaration-list. Doing so causes the property it is used on to become invalid, but does not invalidate the @position-try rule as a whole.

Only that one property is dropped; the rest of the block still applies.

Once a fallback option is active, its declarations take precedence over ordinary Author-origin declarations for the same property, regardless of selector specificity, because the Position Fallback Origin is later in cascade order than the Author origin:

#tooltip {
  top: 10px !important;
}

@position-try --flipped {
  top: anchor(bottom);
  bottom: anchor(top);
}

While --flipped is the active fallback, its top value wins over the #tooltip rule’s top: 10px !important - the Author origin’s !important declaration does not outrank a later cascade origin.

@position-try also only accepts a fixed set of properties: inset properties, margin properties, sizing properties, self-alignment properties, position-anchor, and position-area. Properties outside this list, such as background-color or box-shadow, cannot be varied by a fallback option. A visual change tied to the active fallback (repositioning a tooltip arrow, for example) has to read the applied fallback back out through a separate mechanism, such as an anchored container query, rather than being expressed inside the @position-try block itself.

Fallback option persistence

Once a fallback option succeeds, the spec records it as the last successful position option and gives it priority in subsequent determinations:

In order to maintain layout stability as much as possible, determining position fallback styles prioritizes the last successful position option.

The recorded option is only cleared by a fallback-sensitive change: a change to the computed position value, a change in containing block association, a change to a position-try longhand, or a mutation of a referenced @position-try rule. An element’s own size returning to something that would fit the original position, or an ordinary resize elsewhere on the page, is not on that list.

.tooltip {
  position: fixed;
  position-anchor: --field;
  position-area: block-start;
  position-try-fallbacks: flip-block;
}

If available space above the anchor temporarily shrinks (a banner appears, pushing the anchor down) and the tooltip flips via flip-block, it keeps the flipped position even after the banner is removed and the original space reopens, until one of the fallback-sensitive changes above occurs. Restoring the preferred position after conditions improve requires deliberately triggering one of those changes - typically closing and reopening the positioned element, or toggling a position-try-fallbacks value.

Fallback resolution across multiple elements

Fallback determination for one element does not account for other elements on the page, including ones currently overflowing. The spec states the reasoning directly:

Layout does not “go backward”… At best, this can result in exponential layout costs; at worst, it’s cyclic and will never settle.

If positioning element B changes element A’s containing block (for example, by causing it to gain a scrollbar), A does not re-run its own fallback determination in response. Elements resolve their fallback options strictly in layout order, each one blind to what an element positioned after it will do to the available space. Two anchored elements that would both fit only if their fallback choices accounted for each other’s final footprint will not coordinate; the one earlier in layout order claims its space first, and the later element’s fallback determination runs against whatever containing-block size results from that already-finalized choice.

position-visibility: specified default and implementation

The property’s specified initial value is anchor-visible, meaning an anchor-positioned element is expected by default to hide once its anchor scrolls out of view. Chromium’s shipped initial value is always instead, a divergence the spec editors have acknowledged directly. Code that relies on the specified default - expecting an anchored element to hide automatically once its anchor scrolls away, without setting position-visibility explicitly - will not get that behavior in Chromium; the property needs to be set to anchor-visible explicitly to get it.

The exact condition and timing for the anchor-visibility check is separately still being finalized across engines. WebKit performs the check during layout. Blink’s implementation is closer to an IntersectionObserver, which accounts for factors such as scroll-margin and scroll-padding around the visibility boundary - behavior the CSS Working Group has been resolving incrementally rather than something fully specified up front. Anchored show/hide timing at the edge of a scroll boundary may differ slightly between engines as a result; treat the coarse shown/hidden state as the stable contract rather than the exact frame or scroll-margin boundary at which the transition happens.

Placing an element within a position-area region

position-area selects a region of the position-area grid - a 3×3 grid formed from the edges of the element’s pre-modification containing block and the edges of its default anchor box - and makes that region the element’s containing block. The available keywords select entire rows, columns, or spans of the grid; there is no keyword that selects a corner of a cell directly.

Placement within a region is a second, separate step, handled by align-self and justify-self against the resulting containing block (the inset-modified containing block, after any inset properties shrink it further):

.badge {
  position: absolute;
  position-anchor: --avatar;
  position-area: center span-left;   /* selects the region to the left of the anchor, vertically centered */
  align-self: start;                 /* moves the badge to the top edge of that region */
}

position-area determines which grid cell supplies the containing block; align-self/justify-self determine where inside that cell the element sits. Corner placement is produced by combining a position-area region with the appropriate alignment value, not by a position-area keyword.

position-area is specified to have no effect on an element without a default anchor box. Some implementations apply it anyway, treating the element’s ordinary containing block as the position-area grid when no anchor is present - which can produce a working centering pattern (position-area: center; margin: auto; on a plain position: relative parent, with no anchor at all) that is not part of the specified behavior and is tracked as an open implementation issue rather than a documented technique.

Property renames

Two properties central to this module were renamed after initial shipping, and the renamed forms are the ones defined in the current spec:

Prior nameCurrent name
inset-areaposition-area
position-try-optionsposition-try-fallbacks
position-try-options: inset-area(top left)position-try-fallbacks: top left (the inset-area() wrapper function was removed)

Chromium supported the prior names as aliases through version 131. Example code written against the earlier names will not validate in current implementations without the rename applied.

From the team at

We build digital products and explore the modern web standards behind them.

Related posts