CSS Anchor Positioning and Overlays: Tethering Elements Without JavaScript
Every interface eventually grows something that has to float next to something else: a tooltip beside a help icon, a select menu under its button, a rich hover card beside an avatar, a validation bubble pointing at the field it complains about. For roughly fifteen years the only way to do that reliably was JavaScript — measure the trigger with getBoundingClientRect(), measure the viewport, do the arithmetic, write inline styles, then re-run all of it on every scroll and resize. CSS anchor positioning removes the arithmetic from the equation. This guide sits inside Mastering Container Queries & Responsive Layouts, and it covers the whole feature set: naming an anchor, binding to it, placing against it, and letting the browser retry placements when the first one does not fit.
What this guide covers:
- Declaring anchors with
anchor-nameand binding withposition-anchor - Coarse placement with
position-areaand precise placement withanchor() - Automatic collision handling via
position-try-fallbacksand@position-try - The top layer, the
popoverattribute, and why the two features need each other
Prerequisites
You will move faster through this guide if the following are already familiar:
- Out-of-flow positioning. Containing blocks,
position: absoluteversusposition: fixed, and howinsetshorthand maps to physical offsets. - Logical properties. Anchor positioning speaks
block-start,inline-endand friends, nottopandright. If logical axes are new, read them as "block = the direction text lines stack, inline = the direction text flows." - Feature detection. The
@supportsat-rule and how to test a property/value pair; the guide on feature detection with @supports covers the syntax in depth. - Discrete transitions.
@starting-styleandtransition-behavior: allow-discrete, because overlays animate betweendisplay: noneanddisplay: block.
The core concept: a named reference frame
The mechanism is a two-part handshake. One element publishes a name. Another element consumes that name and thereby acquires a second reference frame in addition to its containing block.
/* 1. The anchor publishes a dashed-ident name. */
.help-icon { anchor-name: --help; }
/* 2. The anchored element binds to that name and gains a reference frame. */
.help-panel {
position: fixed;
position-anchor: --help;
}
anchor-name accepts a dashed identifier (or a comma-separated list of them, so one element can serve several distinct consumers). It creates no visual effect whatsoever; it is purely a declaration that "this element's border box is addressable under this name."
position-anchor sets the default anchor element for the element it is declared on. Once that link exists, the anchored element can be placed relative to the anchor's border box rather than relative to its containing block. Crucially, the containing block does not disappear — it still bounds scrolling and still supplies the fallback rectangle. Anchor positioning adds a coordinate system; it does not replace one.
Three constraints govern the whole feature and explain most of the confusion people hit:
- The anchored element must be out of flow.
position: absoluteorposition: fixed. A static or relative element with a perfectly validposition-anchorsimply lays out normally and ignores every anchor rule. - The anchor must be visible to the anchored element. The anchor has to be an element that the anchored element could, in principle, reach — in practice, it must precede the anchored element in tree order or be one of its ancestors, and it must not be inside a skipped subtree such as
content-visibility: hidden. - Name collisions resolve to the last declaration in tree order. Reusing one name across twenty list rows does not silently break — it silently picks the twentieth row. Scope names per instance when it matters.
Syntax and parameters
| Token | Accepted values | Default |
|---|---|---|
anchor-name | none, or one or more dashed idents (--menu, --menu, --alt) | none |
position-anchor | auto, or a dashed ident naming an anchor | auto |
position-area | one or two keywords from the block/inline row-column grid: start, center, end, span-start, span-end, span-all, plus physical aliases top/bottom/left/right | none |
anchor() | anchor(<side>) or anchor(--name <side>, <fallback>); sides are start, end, self-start, self-end, center, inside, outside, or a percentage | — |
anchor-size() | anchor-size(<axis>) or anchor-size(--name <axis>); axes are width, height, block, inline, self-block, self-inline | — |
position-try-fallbacks | none, or a list of flip-block, flip-inline, flip-start, a position-area() value, or a @position-try rule name | none |
position-try-order | normal, most-width, most-height, most-block-size, most-inline-size | normal |
position-visibility | always, anchors-visible, no-overflow | always |
@position-try --name { … } | a block of inset, margin, position-area, sizing and alignment declarations | — |
Two shorthands are worth memorising. position-try combines order and fallbacks in one declaration. And inset-area was the property's name during the early Chrome ship; it was renamed to position-area before the feature stabilised, so treat any tutorial mentioning inset-area as pre-2025 material.
Step-by-step implementation
Step 1 — Tether without doing any arithmetic
Start with the simplest possible pairing: a panel that hangs below a button. The demo below is the whole feature in miniature — nothing measures anything.
.tether-btn {
anchor-name: --account;
}
.tether-panel {
position: fixed;
position-anchor: --account;
position-area: block-end center;
margin-block-start: 0.5rem;
}
position: fixed here is deliberate rather than incidental. Because the anchored element takes its geometry from the anchor rather than from an ancestor, a fixed element is no longer stuck to the viewport — the browser tracks the anchor as it scrolls and moves the panel with it. This is the single biggest ergonomic win: you get scroll-following behaviour without a scroll listener, and because the panel has no positioned ancestor, no ancestor's overflow: hidden can clip it.
Step 2 — Place coarsely with position-area
position-area treats the anchor as the centre cell of a three-by-three grid and lets you name the cell (or span of cells) the anchored element should occupy. One keyword sets both axes symmetrically; two keywords set block then inline.
.pa-anchor { anchor-name: --hub; }
.pa {
position: fixed;
position-anchor: --hub;
margin: 0.3rem;
}
.pa--ts { position-area: block-start span-inline-start; }
.pa--tc { position-area: block-start center; }
.pa--te { position-area: block-start span-inline-end; }
.pa--ms { position-area: center inline-start; }
.pa--me { position-area: center inline-end; }
.pa--bs { position-area: block-end span-inline-start; }
.pa--bc { position-area: block-end center; }
.pa--be { position-area: block-end span-inline-end; }
The span-* keywords are what make this practical for real menus. block-end span-inline-end means "below the anchor, occupying the anchor's own inline column plus the column after it" — the classic dropdown that is left-aligned with its button and grows rightwards, which is otherwise a fiddly left: 0 plus min-width dance.
position-area also does alignment for free. Placing an element in a cell sets its self-alignment to fill that cell sensibly, which is why the panels above are centred without a single translate: -50%.
Step 3 — Place precisely with anchor()
When a grid cell is too blunt, anchor() returns a length: the position of one edge of the anchor, usable anywhere a <length-percentage> is valid.
.callout {
position: fixed;
position-anchor: --field;
/* Top edge of the callout sits on the bottom edge of the anchor. */
top: anchor(bottom);
/* Left edges align. */
left: anchor(left);
/* Never wider than the anchor. */
max-width: anchor-size(width);
/* Never narrower than half of it. */
min-width: calc(anchor-size(width) / 2);
}
Because anchor() resolves to a plain length, it composes with calc(), min(), max() and clamp(). That is how you build things a placement grid cannot express: a caret that tracks the anchor's horizontal centre while the bubble itself is clamped to the viewport, or a mega-menu whose height is calc(100vh - anchor(bottom) - 1rem).
anchor() accepts a fallback as its final argument — anchor(--maybe bottom, 100%) — which is what keeps a rule from being dropped as invalid at computed-value time when the named anchor is missing. Without a fallback, an unresolvable anchor() makes the whole declaration behave as if the element had no such inset.
Step 4 — Let the browser handle collisions
A tethered element that renders half off-screen is worse than no tethering at all. position-try-fallbacks gives the browser an ordered list of alternative placements; it lays the element out with the primary placement, checks whether it overflows its inset-modified containing block, and if so re-runs layout with the next candidate until one fits or the list is exhausted.
.menu {
position: fixed;
position-anchor: --menu-btn;
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
The three built-in keywords mirror the placement across an axis: flip-block swaps block-start for block-end, flip-inline swaps the inline sides, and flip-start transposes the two axes entirely (turning a below-placement into a beside-placement). Combining two keywords in one candidate, as in the last entry above, produces a diagonal flip.
For anything the keywords cannot express, declare a named candidate:
@position-try --menu-stacked {
position-area: block-end span-all;
width: anchor-size(width);
margin: 0.25rem 0 0;
}
.menu {
position-try-fallbacks: flip-block, --menu-stacked;
}
A @position-try block may only contain inset properties, margin, sizing, self-alignment and position-area. Anything else — colours, borders, transitions — is ignored, which keeps fallbacks cheap: the browser is re-running layout for the candidate, not restyling the subtree.
position-try-order changes the selection strategy from "first that fits" to "the one that yields the most space." position-try-order: most-block-size is the right choice for a long dropdown that should open in whichever direction gives it the most room, even when both directions technically fit.
Finally, position-visibility: anchors-visible hides the anchored element outright when its anchor scrolls out of view. Without it, a tooltip stays pinned at the edge of the scrollport pointing at nothing.
The top layer, and where popover comes in
Anchor positioning solves where. It says nothing about stacking, and stacking is the other half of every overlay bug ever filed. A dropdown inside a card with overflow: hidden gets clipped. A modal inside an ancestor with transform or filter finds that its position: fixed is now relative to that ancestor rather than the viewport. A tooltip inside a z-index: 1 section can never rise above a z-index: 2 sibling section, no matter how large its own z-index grows, because stacking contexts nest.
The top layer is the browser's escape hatch. It is a viewport-sized rendering layer that sits above the entire document, outside every stacking context, immune to ancestor overflow, transform, filter and contain. Elements in it paint in the order they were promoted, so the last one promoted is on top — z-index between top-layer elements is irrelevant.
Only three things put an element in the top layer: a fullscreen request, <dialog> opened with showModal(), and an element with the popover attribute being shown. The first two need JavaScript. The third does not:
<button popovertarget="filters">Filters</button>
<div id="filters" popover>…</div>
That markup gives you a panel that opens on click, closes on Escape, closes when you click outside it (light dismiss), sits in the top layer, and is exposed to assistive technology with an implicit relationship between button and panel — all from two attributes and no script. The popover attribute and CSS styling page takes that markup apart in detail, including ::backdrop and entry/exit animation.
The reason these two features arrived together is that each creates a problem the other solves. A top-layer element has no positioned ancestor by definition, so before anchor positioning the only way to place a popover next to its trigger was — again — JavaScript measurement. And an anchored element that is not in the top layer remains vulnerable to every clipping and stacking hazard listed above. Used together you get an overlay that is both correctly stacked and correctly placed with no script at all.
One convenience worth knowing: an element opened via popovertarget gets an implicit anchor reference, so position-anchor: auto resolves to the invoking button without either element declaring a name. Explicit names are still preferable for anything reused, because implicit anchoring only works through the popover invoker relationship.
Annotated production example: a filter menu
The following is a complete, copy-paste dropdown that combines everything above: top-layer stacking from popover, placement from position-area, collision handling from position-try-fallbacks, width matching from anchor-size(), and animated entry using discrete transitions.
<div class="toolbar">
<button class="filter-btn" popovertarget="filter-menu">
Filters
<span aria-hidden="true">▾</span>
</button>
<div id="filter-menu" popover class="filter-menu">
<p class="filter-menu__label" id="filter-label">Show items that are</p>
<ul class="filter-menu__list" aria-labelledby="filter-label">
<li><label><input type="checkbox" checked> In stock</label></li>
<li><label><input type="checkbox"> On sale</label></li>
<li><label><input type="checkbox"> New arrivals</label></li>
</ul>
</div>
</div>
.filter-btn {
/* Publishes the reference frame. No visual effect on the button itself. */
anchor-name: --filter-btn;
display: inline-flex;
gap: 0.4rem;
align-items: center;
font: inherit;
padding: 0.5rem 0.9rem;
border: 1px solid #d3d8e0;
border-radius: 0.5rem;
background: #fff;
}
.filter-menu {
/* Popover elements default to position: fixed and margin: auto in the UA
sheet; both are reset here so our own placement wins predictably. */
position: fixed;
margin: 0;
position-anchor: --filter-btn;
/* Below the button, aligned to its inline-start edge, growing outwards. */
position-area: block-end span-inline-end;
/* Try above, then mirrored, then the diagonal, before giving up. */
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
/* Never narrower than the button; never wider than a readable measure. */
min-width: anchor-size(width);
max-width: 22rem;
/* Never taller than the space between the button and the viewport edge. */
max-height: calc(100vh - anchor(bottom) - 1rem);
overflow-y: auto;
/* Hide it entirely once the button scrolls out of the scrollport. */
position-visibility: anchors-visible;
margin-block-start: 0.4rem;
padding: 0.75rem;
border: 1px solid #d3d8e0;
border-radius: 0.6rem;
background: #fff;
box-shadow: 0 12px 32px rgb(15 23 42 / 0.16);
}
/* Entry and exit. display and overlay are discrete properties, so they need
allow-discrete to participate in the transition at all. */
.filter-menu {
opacity: 0;
translate: 0 -0.4rem;
transition:
opacity 0.18s ease,
translate 0.18s ease,
display 0.18s allow-discrete,
overlay 0.18s allow-discrete;
}
.filter-menu:popover-open {
opacity: 1;
translate: 0 0;
}
/* The pre-open state, required because the element goes from display:none
straight to its open state with no prior computed value to animate from. */
@starting-style {
.filter-menu:popover-open {
opacity: 0;
translate: 0 -0.4rem;
}
}
.filter-menu__label { margin: 0 0 0.5rem; font-weight: 600; }
.filter-menu__list { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.4rem; }
.filter-menu__list label { display: flex; gap: 0.5rem; align-items: center; }
@media (prefers-reduced-motion: reduce) {
.filter-menu { transition: display 0.18s allow-discrete, overlay 0.18s allow-discrete; translate: none; }
.filter-menu:popover-open { translate: none; }
}
Three details in there repay attention. The margin: 0 reset matters because the user-agent stylesheet gives [popover] a centring margin: auto, which fights position-area in confusing ways. The overlay property in the transition list is what keeps the element in the top layer for the duration of the exit animation — omit it and the panel drops out of the top layer instantly on close, so it appears to jump behind other content while fading. And max-height built from anchor(bottom) means the menu scrolls internally instead of being pushed off-screen when the list is long. The mechanics of discrete transitions are covered end to end in transitioning display with allow-discrete and @starting-style entry animations.
Performance and accessibility notes
Layout cost is real but bounded. Each fallback candidate the browser tries is an additional layout pass for that element's subtree. A three-candidate chain on a small dropdown is negligible; the same chain on twenty simultaneously-open anchored elements each containing a large subtree is not. Keep chains short, keep anchored subtrees small, and prefer position-area candidates over @position-try blocks that also change sizing, since a size change can cascade into the subtree.
Do not animate anchored placement. Transitioning top or inset on an anchored element forces layout every frame and fights the fallback machinery, which may pick a different candidate mid-transition. Animate opacity, translate and scale only — those stay on the compositor and never invalidate the placement.
Position is not semantics. This is the accessibility headline for the entire feature. Anchor positioning moves pixels; it creates no relationship in the accessibility tree. A panel that visually hugs a button is, to a screen reader, an unrelated region somewhere in the DOM. You still need aria-describedby for descriptive popovers, aria-labelledby or an accessible name for panels, a real <button> as the trigger, and DOM order that puts the panel immediately after its trigger so that sequential navigation reaches it in a sane order. The anchor positioned tooltips page works through the full association pattern, and the CSS-only approach in accessible CSS-only tooltips covers the WCAG 1.4.13 requirements that apply either way.
Respect motion preferences. Overlays that fly in from a distance are a documented vestibular trigger. Under prefers-reduced-motion: reduce, keep the opacity fade and drop the translation, as the example above does — but keep the allow-discrete entries in the transition list, or the element will vanish before the exit finishes.
Focus order still needs care. popover handles Escape and light dismiss, and it moves focus back to the invoker on close, but it does not trap focus (that is <dialog> with showModal()). For a non-modal menu that is correct behaviour, not a bug.
DevTools debugging workflow
- Confirm the binding. Select the anchored element in the Elements panel. In Chrome DevTools, a
position-anchordeclaration whose name resolves shows a small anchor badge next to it; hovering the badge highlights the anchor element in the viewport. No badge means the name did not resolve — check tree order and spelling of the dashed ident. - Check the element is out of flow. In Computed styles, filter for
position. If it readsstaticorrelative, every anchor property on the element is inert. This is the most common cause of "my rule does nothing." - Watch the fallback chain fire. With the element selected, drag the DevTools window edge to shrink the viewport and watch the Computed panel. The applied
position-areachanges as candidates are tried, so you can see exactly which candidate won at a given size. - Inspect the top layer. In the Elements tree, a shown popover is annotated with a
#top-layermarker; expanding it shows both the element and its::backdrop. If your popover is not listed there, it never opened. - Force the open state. Use the
:hovpane to force:popover-openso you can style the panel without it light-dismissing every time you click into DevTools. - Profile if placement feels janky. Record in the Performance panel while scrolling a page with anchored elements. Repeated "Layout" entries attributed to the anchored subtree point at an over-long fallback chain or at animated insets.
Browser compatibility
| Feature | Chrome | Edge | Safari | Firefox |
|---|---|---|---|---|
anchor-name / position-anchor | 125+ | 125+ | 26+ | 147+ |
position-area | 129+ | 129+ | 26+ | 147+ |
position-try-fallbacks / @position-try | 128+ | 128+ | 26+ | 147+ |
popover attribute | 114+ | 114+ | 17+ | 125+ |
@starting-style | 117+ | 117+ | 17.5+ | 129+ |
transition-behavior: allow-discrete | 117+ | 117+ | 17.4+ | 129+ |
The headline is that anchor positioning is no longer a one-engine experiment. Chrome and Edge shipped the binding properties in 125, with position-try-fallbacks following in 128 and position-area in 129 after the rename from inset-area. Safari shipped the feature in version 26, and Firefox shipped it in 147. anchor() and anchor-size() ship alongside the binding properties in each of those engines. Overlay behaviour — the popover attribute and discrete transitions — has been interoperable for considerably longer, so the two halves of this section now have the same answer: they work everywhere current.
What that changes is the shape of the fallback problem. You are no longer writing a second implementation for one hold-out engine; you are writing a graceful floor for the older versions still in circulation — anything below Chrome/Edge 129, Safari 26 or Firefox 147. That is a much cheaper obligation, and it is the subject of anchor positioning fallbacks.
Common pitfalls
| Pitfall | Cause | Resolution |
|---|---|---|
| Anchor properties have no effect | The anchored element is still position: static or relative | Set position: fixed (or absolute) on the anchored element; anchor placement only applies to out-of-flow boxes. |
Popover ignores position-area and sits centred | The UA stylesheet's margin: auto on [popover] is still in effect | Declare margin: 0 on the popover, then add directional margins for the gap you want. |
| Panel jumps behind other content while closing | overlay is not in the transition list, so the element leaves the top layer immediately | Add overlay <duration> allow-discrete alongside display in the transition declaration. |
| Wrong element is used as the anchor in a list | Several instances share one anchor-name; the last in tree order wins | Give each instance a distinct name, or open the panel via popovertarget and rely on the implicit anchor. |
| Tooltip stays pinned at the scrollport edge pointing at nothing | Default position-visibility: always keeps it painted after the anchor scrolls away | Set position-visibility: anchors-visible on the anchored element. |
FAQ
Does anchor positioning replace positioning libraries entirely? For the common cases yes. Tethering, edge collision flipping, and size matching are all expressible in CSS now, and every current engine ships the feature. Libraries still earn their place for virtual anchors that follow a cursor, arrow elements that must stay clamped to the anchor edge in every fallback, and codebases whose support floor still includes browser versions released before the feature landed.
Do I need the popover attribute to use anchor positioning?
No. The two features are independent. Anchor positioning works on any absolutely or fixed positioned element, and popover works without any anchor rules. They are designed to compose because a top-layer element has no positioned ancestor to anchor to, which is exactly the gap anchor positioning fills.
Why does my anchored element ignore position-area?
Almost always because it is not out of flow. position-area, anchor() and position-try-fallbacks only apply to elements with position set to absolute or fixed. A statically positioned element with a valid position-anchor still lays out normally.
Can one anchor name be used by several elements?
Yes, and this is the usual case in a list. If more than one element in scope carries the same anchor-name, the anchored element resolves against the last one in tree order, so scope the name with a per-instance custom property or rely on the implicit anchor from popovertarget.
Related
- Mastering Container Queries & Responsive Layouts — the parent guide this section belongs to.
- Anchor positioned tooltips — a complete tethered tooltip with flipping and correct ARIA association.
- Popover attribute and CSS styling — light dismiss,
::backdrop, and script-free entry animation. - Anchor positioning fallbacks — a graceful floor for older browser versions without maintaining two components.
- Transitioning display with allow-discrete — cross-area: the transition mechanics every overlay depends on.
Related articles
More pages in the same section.