The Tooltip That Won't Fall Off
Pinning a menu or tooltip to a button used to mean JavaScript. CSS anchor positioning does it instead: tether with anchor(), place with position-area, size with anchor-size(), and let position-try flip it at an edge.
A tooltip pinned under a button sounds like a solved problem. It isn’t, and it hasn’t been for 25 years. The moment the float has to stay attached while the page scrolls, and flip above the button when it runs out of room below, you reach for JavaScript: getBoundingClientRect() on the trigger, absolute coordinates on the float, a scroll listener to keep them in sync, and a branch that measures the viewport and flips sides. That is most of what a library like Floating UI exists to do. Anchor positioning moves the whole job into CSS: tether one element to another, place it, size it to its anchor, and let the browser flip it when there’s no room.
Tether it
Name the anchor with anchor-name. Point the float at that name with position-anchor, then read the anchor’s edges with anchor(). The dot below moves on a pure CSS loop, and the label reads bottom: anchor(top) every frame, so it stays glued above it. No script measures anything.
.dot {
anchor-name: --dot; /* name the thing to attach to */
}
.label {
position: absolute;
position-anchor: --dot; /* attach to it */
bottom: anchor(top); /* my bottom sits on its top edge */
left: anchor(center); /* my left starts at its centre */
translate: -50% -12px; /* center on it, add a gap */
}
anchor(top) resolves to the anchor’s top edge in the float’s own coordinate space, so bottom: anchor(top) reads as “put my bottom edge where its top edge is.” anchor(center) plus a -50% translate centres the two. That is the entire tether, and it survives scroll and layout shifts because the browser recomputes it, not a listener you wrote.
Place it
Writing top, left, and a translate for every side gets old. position-area treats the anchor as the middle of a 3×3 grid and drops the float into whichever cell you name. One property, nine placements. Pick a cell:
.float {
position: absolute;
position-anchor: --anchor;
position-area: bottom center; /* the cell below the anchor */
margin: 8px; /* gap, in whatever direction it lands */
}
The one JavaScript line here sets --area to a string like top right. Everything about where the float lands, and which of its own edges lines up with the anchor, is the browser reading position-area. center center parks it right over the anchor. margin becomes the gap, and it points outward from the anchor no matter which cell you chose, so you set it once.
Match its size
A dropdown should be exactly as wide as the button that opened it. anchor-size() reads the anchor’s dimensions, so width: anchor-size(width) locks the menu to the trigger’s width. Resize the trigger and the menu tracks it, nothing measured:
.trigger { anchor-name: --trigger; }
.menu {
position: absolute;
position-anchor: --trigger;
top: calc(anchor(bottom) + 8px); /* under the trigger */
left: anchor(left); /* left edges aligned */
width: anchor-size(width); /* as wide as the trigger */
}
I have written this width sync in JavaScript more than once: a ResizeObserver on the trigger pushing a pixel value onto the menu on every resize. anchor-size() deletes all of it. It takes any dimension, width, height, block, inline, so a caret or a connector line can be sized off the anchor too. The menu never has to know its own width, because it reads the trigger’s.
Let it flip
The last piece is the one that used to need viewport math. position-try-fallbacks hands the browser a list of alternate placements and lets it pick the first that fits. flip-block mirrors your placement across the block axis, so a menu that opens below its trigger jumps above the moment it would run off the bottom of the screen. flip-inline does the same horizontally, and you can spell out your own order, which the browser walks until one clears:
.menu {
position: absolute;
position-anchor: --trigger;
position-area: bottom; /* open below */
position-try-fallbacks: flip-block; /* no room below? open above */
}
The browser measures against the viewport and any scrolling ancestor, the same rectangle a library would have measured with getBoundingClientRect(). The resize observer, the scroll listener, the branch that flips sides: all of it collapses into that one declaration. I kept it out of the demos above on purpose, it only shows itself at the very edge of the screen, which is exactly where it earns its place.
When it isn’t there
Chrome and Edge shipped the whole set in 125 (mid-2024). Safari and Firefox were both still implementing it when I built this, so treat it as progressive enhancement, not a baseline you can lean on yet.
The nice part is how cleanly it degrades. A browser that has never heard of anchor(), position-area, or anchor-size() throws those declarations out as invalid and the float keeps whatever position you gave it in plain CSS. Wrap the enhancement in @supports (anchor-name: --a) { … } and you get the tethered, self-sizing version where it’s supported and a sensible static one everywhere else. It replaces a positioning library where you can afford to, and costs nothing where you can’t.