Mert Özen Further With Every Line
Frontend CSS3: Flexbox, Grid and Modern CSS — #15

Anchor Positioning: Tooltips, Dropdowns and Popovers Without JavaScript

Mert Özen Nov 14, 2026 15 min 1 views
Anchor Positioning: Tooltips, Dropdowns and Popovers Without JavaScript

Anchor positioning places an element relative to another with CSS; a popover opened by a button takes that button as its anchor, and position-area with flip-block gives a menu and a preview without JavaScript. Its core became Baseline with Firefox 147 in January 2026; web-features shows 'limited' today because the position-visibility values were renamed.

Version note: This post follows the CSS Anchor Positioning Level 1 specification and the Baseline status of November 2026; the examples were run in Chrome 158 and WebKit 27.2 (Playwright 1.65), and Firefox support is taken from the web-features 3.46 data and the Firefox 147 release notes.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, we want to put a small label under every cover in the book list. A teammate is trying anchor positioning for the first time and gives every cover the same anchor name, and every label that name:

/* the list items are not positioned: every tip's containing block is the viewport */
li { inline-size: 100px; block-size: 60px; }

.cover { anchor-name: --cover; block-size: 40px; }
.tip { position: absolute; position-anchor: --cover; position-area: block-end; }
<ul class="plain">
    <li><div class="cover">Dune</div><div class="tip">tip</div></li>
    <li><div class="cover">Emma</div><div class="tip">tip</div></li>
    <li><div class="cover">Middlemarch</div><div class="tip">tip</div></li>
</ul>

Does every label end up under its own cover, or somewhere else?

In this post we look at anchor positioning, which places an element relative to another element: anchor(), anchor-size() and position-area, position-try-fallbacks for changing direction when there is no room, and a user menu and a book preview without JavaScript using popover and invoker commands. Then we look at this feature's Baseline status, because the answer is a little more complicated than it seems at first.

anchor-name, anchor() and anchor-size()

Anchor positioning has two sides. The anchor element gets a name with anchor-name (the name starts with two dashes, like a custom property). The positioned element must be position: absolute or fixed and picks that name with position-anchor. The lab page has three examples:

.anchor { position: absolute; inline-size: 120px; block-size: 40px; }
.tip { position: absolute; inline-size: 160px; block-size: 60px; margin: 0; }

/* 1) anchor() and anchor-size(): the tip's top edge on the anchor's bottom edge, at least as wide as the anchor */
#a1 { anchor-name: --a1; inset-block-start: 100px; inset-inline-start: 100px; }
#t1 {
    position-anchor: --a1;
    inset-block-start: anchor(bottom);
    inset-inline-start: anchor(left);
    min-inline-size: anchor-size(width);
    inline-size: auto;
}

/* 2) position-area: a 3x3 grid around the anchor; span-inline-end = start at the anchor, run to the right */
#a2 { anchor-name: --a2; inset-block-start: 100px; inset-inline-start: 400px; }
#t2 { position-anchor: --a2; position-area: block-end span-inline-end; }

/* 3) not enough room below: flip-block tries the mirror position above the anchor */
#a3 { anchor-name: --a3; inset-block-start: 520px; inset-inline-start: 100px; }
#t3 { position-anchor: --a3; position-area: block-end; position-try-fallbacks: flip-block; }
#t3-none { position-anchor: --a3; position-area: block-end; }
node scripts/15-anchor.mjs
--- chromium
a1      100,100 120x40
t1      100,140 120x60
a2      400,100 120x40
t2      400,140 160x60
a3      100,520 120x40
t3      80,460 160x60
t3-none 80,560 160x60
same name:   covers at x 20 140 260, tips at x 260 260 260
anchor-scope: covers at x 20 140 260, tips at x 20 140 260
anchor outside the containing block: tip 27,227 50x20, box 20,220 300x80
scrollTop   0: anchor 20,370 200x20, tip 220,370 80x20, hit test at the tip finds the tip
scrollTop  40: anchor 20,330 200x20, tip 220,330 80x20, hit test at the tip finds the tip
scrollTop 200: anchor 20,170 200x20, tip 220,170 80x20, hit test at the tip finds something else
position-visibility: anchors-visible true, anchor-visible true, computed "anchor-visible"
user menu: button 1201,7 63x42, menu 1124,53 140x90
title at y 450: preview 142,276 288x170, above the title
title at y 90: preview 142,123 288x170, below the title
--- webkit: 
a1      100,100 120x40
t1      100,140 120x60
a2      400,100 120x40
t2      400,140 160x60
a3      100,520 120x40
t3      80,460 160x60
t3-none 80,560 160x60
same name:   covers at x 20 140 260, tips at x 260 260 260
anchor-scope: covers at x 20 140 260, tips at x 20 140 260
anchor outside the containing block: tip 27,227 50x20, box 20,220 300x80
scrollTop   0: anchor 20,370 200x20, tip 220,370 80x20, hit test at the tip finds the tip
scrollTop  40: anchor 20,330 200x20, tip 220,330 80x20, hit test at the tip finds the tip
scrollTop 200: anchor 20,170 200x20, tip 220,170 80x20, hit test at the tip finds the tip
position-visibility: anchors-visible true, anchor-visible true, computed "anchor-visible"
user menu: button 1201,7 63x42, menu 1124,53 140x90
title at y 450: preview 142,276 288x170, above the title
title at y 90: preview 142,123 288x170, below the title

In the first example the anchor() function turns an edge of the anchor into a length that can be used in inset properties: inset-block-start: anchor(bottom) means "my top edge is at the anchor's bottom edge". In the output a1 is at 100,100 and 40px tall, t1 is at 100,140: directly below it, with the left edges aligned. anchor-size(width) gives the anchor's width; with min-inline-size the tip is at least as wide as the anchor, 120px. This is for cases like a select's drop-down list being as wide as its button.

The second example is a shorter route: position-area. Think of the area around the anchor as a 3x3 grid; the middle cell is the anchor itself. block-end span-inline-end means "in the bottom row, from the column where the anchor starts, towards the right". t2 is below the anchor (y 140) and starts at the same x as the anchor (400). In most cases position-area is easier to read than four anchor() calls and works better together with changing direction; I try it first and switch to anchor() when I need pixel-exact edge alignment.

When It Does Not Fit: position-try-fallbacks

The anchor in the third example is at the very bottom of the 600px window, at y 520. t3-none, which wants a 60px tip below it, starts at 560 and overflows the window by 20 pixels. t3 has position-try-fallbacks: flip-block: the browser first tries the requested position, and when it does not fit, tries the position mirrored on the block axis, that is, above the anchor. t3 is at 460, right above the anchor. flip-inline does the same horizontally; both can be written together. For more control, named alternative positions can be defined with @position-try, and position-try-order can give the browser a priority such as "pick the position that leaves the most height".

Note: the browser measures the fit against the containing block. For a position: fixed or top-layer element, that is the visible window. For a tip positioned absolute inside a position: relative ancestor, it is that ancestor's box.

A User Menu with Popover and Invoker Commands

I added a user menu to Bookstore's header, next to the cart button. The menu is a popover, and the button opens it with invoker commands, which we saw on the cart dialog in the previous post:

<button class="user-button btn" type="button" commandfor="user-menu" command="toggle-popover">Ada</button>
<div id="user-menu" class="user-menu" popover>
    <ul>
        <li><a href="orders.html">My orders</a></li>
        <li><a href="account.html">Account settings</a></li>
        <li><a href="sign-out.html">Sign out</a></li>
    </ul>
</div>

The menu has no anchor-name and no position-anchor. When a popover is opened by a button (with commandfor, popovertarget or showPopover({ source })), that button becomes the popover's default anchor. Thanks to this "implicit anchor" the same CSS works for every popover on the page, and there are no name clashes. The styles live in a new component file:

.user-menu,
.book-preview {
    padding: 0.5rem;
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    background-color: var(--color-surface);
    color: var(--color-text);
}

.user-menu ul {
    margin: 0;
    padding: 0;
    list-style: none;
}

.book-preview {
    inline-size: min(18rem, 100vw - 2rem);
}

/* Without anchor positioning the popovers stay where the UA stylesheet puts them: centred in the viewport.
   With it, a popover opened by a button gets that button as its anchor: no anchor-name and no script. */
@supports (position-area: block-end) {
    .user-menu,
    .book-preview {
        /* the UA centring (inset: 0, margin: auto) gives way to position-area */
        margin: 0;
        /* not enough room on the preferred side: try the mirror position on the other side */
        position-try-fallbacks: flip-block;
    }

    /* under the button, right edges lined up, at least as wide as the button */
    .user-menu {
        position-area: block-end span-inline-start;
        min-inline-size: anchor-size(inline);
        margin-block-start: 0.25rem;
    }

    /* above the book title, centred on it */
    .book-preview {
        position-area: block-start;
        margin-block-end: 0.25rem;
    }
}

/* the title button looks like the title it replaces */
.preview-button {
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    text-align: start;
    text-decoration: underline dotted;
    cursor: pointer;
}

In the "user menu" line of the output the button is at 1201,7, 63x42; the menu is at 1124,53 and 140 pixels wide. The menu's right edge (1124 + 140 = 1264) matches the button's right edge, and its top edge is 4 pixels below the button (margin-block-start: 0.25rem). span-inline-start makes the menu extend to the left from the column where the anchor ends; the right choice for the menu of a button at the right end of the header. min-inline-size: anchor-size(inline) has no effect here because the menu's content is already wider than the button; for a one-word menu it keeps the menu from being narrower than the button.

Because the popover opens in the top layer, it does not compete with the sticky header's z-index: 10 or the list's isolation: isolate. The z-index problem we described in the positioning post of this series does not arise here at all.

One more detail: the browser's own stylesheet puts a popover in the middle of the window with inset: 0 and margin: auto. With position-area, placement relative to the anchor takes over, and I remove the automatic margin with margin: 0. I put all of this inside @supports (position-area: block-end): in a browser without anchor positioning the menu still opens, just in the middle of the window. Had I written margin: 0 outside @supports, the menu would stick to the top-left corner in those browsers.

The Book Preview Popover

The book titles in the catalog list are now buttons and show a short summary of the book when clicked:

<h2 class="book-title"><button class="preview-button" type="button" commandfor="preview-dune" command="toggle-popover">Dune</button></h2>
<div id="preview-dune" class="book-preview" popover>
    <p>A noble family takes over the desert planet Arrakis, the only source of the most valuable substance in the universe.</p>
    <a href="book.html">Open the book page</a>
</div>

By default the preview sits above the title (position-area: block-start), centred on it. When the page is scrolled and the title gets close to the top edge of the window, there is no room above and flip-block moves the preview below the title. The last two lines of the output measure this: with the title at y 450 the preview is at 276, above; with the title at y 90 the preview is at 123, below. The script opens the preview again each time, but an open popover also follows its anchor while scrolling and changes direction when needed; I did not write a single line of JavaScript for that. I made the measurement with reducedMotion: 'reduce', because the scroll-driven slide we added to the cards in the previous post was also moving the anchor during the measurement.

When I first added the preview, the "Open the book page" link inside it was not underlined. The reason was the donut scope we added to the card in the nesting post of this series: @scope (.book-card) to (.user-content). The popover is drawn in the top layer, but in the DOM it is still inside the card, and selectors look at the DOM. I added [popover] to the scope limit:

@scope (.book-card) to (.user-content, [popover]) {

This is not a tooltip but a preview that opens on click. For tooltips that open on hover, HTML is getting the interestfor attribute (interest invokers), but according to web-features it is only in Chrome for now; a hover-based preview still needs a little JavaScript today. The positioning part works with the same CSS.

Same Names, Containing Blocks and Scroll Containers

The answer to the guessing question is on the lab's second page:

ul { display: flex; gap: 20px; margin: 0; padding: 20px; list-style: none; }
/* the list items are not positioned: every tip's containing block is the viewport */
li { inline-size: 100px; block-size: 60px; }
.tip { position: absolute; inline-size: 100px; block-size: 30px; margin: 0; }

/* 1) the same anchor-name in every list item: every tip finds the last one */
.cover { anchor-name: --cover; block-size: 40px; }
.tip { position-anchor: --cover; position-area: block-end; }
/* 2) anchor-scope: a name is looked up only inside its own list item */
.scoped li { anchor-scope: --cover; }

/* 3) a tip whose containing block (.box) does not contain the anchor: anchor() has no anchor */
.box { position: relative; margin: 20px; inline-size: 300px; block-size: 80px; }
#outside { anchor-name: --outside; position: absolute; inset-block-start: 400px; inset-inline-start: 400px; inline-size: 50px; block-size: 20px; }
#lost { position: absolute; inline-size: 50px; block-size: 20px; inset-block-start: anchor(--outside bottom, 7px); inset-inline-start: anchor(--outside left, 7px); }

/* 4) the anchor scrolls inside a scroller; the tip outside follows it, and can hide when the anchor is gone */
.scroller { position: relative; overflow-y: scroll; margin: 20px; inline-size: 200px; block-size: 100px; }
#scrolled { anchor-name: --scrolled; margin-block-start: 50px; block-size: 20px; }
#follow {
    position: absolute; position-anchor: --scrolled; position-area: inline-end; inline-size: 80px; block-size: 20px;
    /* hide the tip when its anchor has scrolled out of view; Firefox only knows the older plural spelling */
    position-visibility: anchors-visible;
    position-visibility: anchor-visible;
}

In the "same name" line of the output the covers are at x 20, 140 and 260, but all three labels are at x 260: under the last cover, Middlemarch. When a name exists on several elements of the page, the positioned element finds the last valid anchor in tree order; here even Dune's label goes to Middlemarch, which comes after it. Giving every item in a list the same name is a natural habit, and the result is all the labels piled up in one place. anchor-scope: --cover makes a name be looked up only in that element's subtree; in the "anchor-scope" line every label is under its own cover. anchor-scope is also one of the features that arrived with Firefox 147.

It matters that the lab's li elements are not position: relative. If they were, each label's containing block would be its own li and the problem would not show, because for an anchor to be valid it generally has to be inside the positioned element's containing block. That rule is the subject of the third example: the containing block of #lost is .box, while the anchor is outside the box. The anchor is not valid, and anchor() falls back to the value in its second argument (7px); the tip is 7 pixels inside the box's top-left corner (27,227). Without a fallback value the insets would be invalid and the element would stay at its auto position. This is the most common answer to "why is the tooltip in the wrong place?": a position: relative, transform or contain in between. Popovers are not affected, because the containing block of a top-layer element is the window itself; that is one of the reasons I use popovers in Bookstore.

In the fourth example the anchor is inside a scroll container and the tip outside it. At scrollTop 0 the anchor and the tip are at the same y (370); after scrolling 40 pixels both move up to 330 together. The browser tracks the anchor's scroll position. When the anchor leaves the visible area, though, the tip is left floating; that is what position-visibility is for. In Chromium the tip is hidden at scrollTop 200 and the hit test does not find it. In WebKit I checked separately that the tip does not appear in a screenshot, but elementFromPoint still returns it; in Playwright's WebKit build the hidden tip seems to keep receiving clicks.

Baseline Status: January 2026 or Not?

In the lab I wrote position-visibility twice: first anchors-visible, then anchor-visible. The reason is the most confusing topic of this post, the Baseline status. web.dev's January 2026 post says that Firefox 147 supports anchor positioning and that this makes the feature Baseline newly available; the Firefox 147 release notes also say anchor positioning is now enabled by default (January 13, 2026). But in the current web-features 3.46 the status of the anchor-positioning feature is "limited availability": Chrome 157, Safari 27 and no Firefox. The web features explorer also shows the feature as blocked by Firefox since November 2026.

I found the reason by looking at the 325 individual browser compatibility entries that make up the feature in web-features. 323 of them are Baseline; anchor-name, anchor(), anchor-size(), position-area, position-try-fallbacks, anchor-scope and the implicit anchor of popovers since January 13, 2026. The two entries that are not Baseline are position-visibility: anchor-valid and anchor-visible. The specification renamed these values from plural (anchors-valid, anchors-visible) to singular, and says browsers may recognise the old plural spelling as an alias. Chrome 157 and Safari 27 added the singular spelling, while Firefox has only the plural one. Because web-features computes a feature's status from the weakest of its entries, the whole feature started to show as "limited availability" again when Chrome 157 shipped in November 2026. There is also the entry for the position-anchor property, dated September 2026 (Chrome 151, Firefox 151, Safari 27); if you use the implicit anchor of popovers you do not need that property at all.

The practical conclusion: the core of anchor positioning has been in all three engines since January 2026 and can be used as newly available. The only inconsistency is in the value names of position-visibility; writing both spellings one after the other (plural first, then singular) makes every engine use the last value it recognises. Chrome 158 and WebKit 27.2 accept both spellings; the CSS.supports results in the output are true for both, and the computed value is the singular value written last. Anchor positioning is again a focus area in Interop 2026, continuing with only the 2025 tests; in other words, the small behaviour differences between engines are being closed this year too. I could not run Firefox in this environment, so I could not measure Firefox's behaviour; Firefox support comes from the sources above.

When to Drop Libraries Such as Floating UI

Libraries such as Floating UI have done the same job in JavaScript for years: they measure the anchor's position, listen to scroll and resize events, work out the overflow and update the position. Anchor positioning moves this calculation into the browser's layout step; the measuring code, the event listeners and the tooltip that appears in the wrong place on the first frame all go away. My rule is this: for popovers tied to a button, such as tooltips, drop-down menus and previews, CSS is enough if your audience uses browsers from after January 2026. A library still makes sense if you need to support Firefox versions older than 147; if the anchor is not an element but a virtual point such as a text selection or the mouse position; if you want the tooltip to slide along the edge of the screen (there is no direct equivalent of Floating UI's shift in the specification, only a choice between alternative positions you define in advance); or if behaviours such as hover delays and focus management already come from a component library. Tools such as the Angular CDK's overlay also do their positioning in JavaScript; the safest path is to start the switch with one component and measure as you go.

Production Notes

Always think about what a browser without anchor positioning shows. In Bookstore the popovers open in the middle of the window in those browsers; the content is reachable, only the position is not ideal. Putting the positioning inside @supports (position-area: block-end) makes sure of that. If it is essential for understanding that a tooltip stays attached to its anchor (for example a value label on a chart), consider a JavaScript fallback for browsers without support.

The keyboard and screen reader behaviour of popover and invoker commands comes from the browser: closing with Escape, closing on an outside click, focus management. The details are in the HTML5 and Semantic HTML series' post on native interactive elements, and the accessibility reasoning in the upcoming Accessibility (a11y) series.

Common Pitfalls

First, giving every item in a list the same anchor-name and forgetting anchor-scope. As we measured, all the tips go to the last anchor.

Second, leaving the anchor outside the positioned element's containing block. A position: relative in between makes the anchor invalid; give anchor() a fallback value or use an element that lives in the top layer, such as a popover.

Third, not accounting for the margin: auto and inset: 0 a popover gets from the browser. Reset margin when positioning relative to an anchor, but do it inside @supports.

Fourth, forgetting where an element that moves to the top layer still sits in the DOM. A popover inside a card is affected by the card's selectors, its @scope and its inherited properties.

A Small Experiment

★☆☆ Change the position-area of t2 in the lab to inline-end center, guess the tip's new coordinates in the output of node scripts/15-anchor.mjs, then check.

★★☆ Add a named alternative position to the user menu with @position-try --menu-left: if the menu does not fit on the right, it should open to the right from the button's left edge. Narrow the window to 360px, move the button to the left of the header, and measure with a script that the alternative is chosen.

★★★ Add a small arrow pointing at the anchor to the book preview: draw a triangle with ::before and use anchor() to make it always point at the middle of the title. Find out what is needed for the arrow to change direction too when flip-block moves the preview below, and measure that the arrow's tip is at the middle of the title in both positions.

In the last post of the series we step back and look at the big picture: why there is no such thing as "CSS4", CSS Snapshots, the newly and widely available split of Baseline, the Interop process and the chronology of features that arrived from 2023 to 2026; then choosing a Baseline target for your own project and aligning it with the browser targets of Angular, Vite and Tailwind.