Put the box under the button
On the flip that used to be a scroll listener, the two properties that got renamed halfway through last year, and why this is staying off client work.
And if there’s no room under the button, put the box above the button instead.
That’s the requirement. That’s the whole thing. Everybody in the room understands it in about a second, including the client, including the account manager, including me. I have never once built it without help.
Last April I wrote about the popover attribute, which took 6 behaviors I’d been hand-writing for a decade and turned them into markup. I ended that post saying the positioning half hadn’t arrived yet. It’s arrived. One engine so far, and it’s the last piece of a thing I’ve been watching get assembled for about 5 years. I rebuilt a component with it over the holidays just to see what happened.
How hard can it be?
Worth spelling out why this was ever difficult, because “box under button” sounds like two declarations and has never in its life been two declarations.
To put a panel under a trigger you need the trigger’s position in the viewport, the panel’s own width and height, the size of the viewport, and the scroll offset of every scrolling ancestor between the two of them. Then you need a policy for what happens when the panel would hang off the bottom of the screen, so flip it above, and off the right edge, so slide it back along.
Then you redo all of that whenever anything scrolls, the window resizes, the panel’s contents change, or the panel’s contents finish loading and change its height out from under you.
That’s why Floating UI and everything before it exist. They’re good libraries written by people who clearly care. They are also a few KB of measurement running on scroll to compute a number the layout engine already has sitting right there. The engine knows where the button is. It knows exactly. It just never had a way to tell you.
And because all that measuring happens after layout rather than during it, the panel is always a frame or so behind on a fast scroll. Not badly. Just enough that if you ever go looking for it you’ll see it, and then you can’t unsee it, sorry lol
Hi, my name is
The mechanism is about as plain as it could have been. One element declares a name, another element positions itself against that name.
.filters-button {
anchor-name: --filters;
}
.filters-panel {
position: absolute;
position-anchor: --filters;
/* my top edge sits on the anchor's bottom edge */
top: anchor(bottom);
left: anchor(left);
margin-top: 0.5rem;
}
anchor() resolves to a position on the anchor element, translated into the coordinate space of the thing being positioned. So anchor(bottom) means the anchor’s bottom edge, expressed from where you’re standing.
Writing 4 separate offsets by hand gets old fast. So there’s a shorthand, and the shorthand is tic-tac-toe. The anchor’s own edges cut the space around it into 9 cells. You name the cell you want.
.filters-panel {
position: absolute;
position-anchor: --filters;
/* below the button, left edges lined up */
position-area: block-end span-inline-end;
}
span-inline-end is the panel taking two cells instead of one. So it starts at the anchor’s inline start edge and runs outward instead of centering itself. Nine cells and a couple of span keywords covers, I’d guess, 90% of what anyone actually wants a panel to do.
There’s also anchor-size(), which I didn’t see coming. min-width: anchor-size(width) makes the panel at least as wide as the thing it hangs off, so a dropdown lines up with its trigger without anybody measuring anything.
All of this reaches across the top layer, which matters most for the popover work. A popover renders on a separate surface above the entire document. It can still anchor itself to an element sitting down in the regular document. That combination is exactly what wasn’t possible before. You got correct stacking or you got convenient positioning, and now both. I keep having to remind myself that’s allowed.
I think a popover opened by popovertarget gets an implicit anchor as well, so you don’t have to invent a name at all. I’ve been writing the name out anyway, because I don’t trust myself yet.
If at first you don’t fit
The flip is the genuinely hard piece, and the one that eats most of the library.
You hand the browser an ordered list of alternative placements and it walks the list until it finds one that fits.
@position-try --narrow-side {
position-area: inline-end;
width: 12rem;
}
.filters-panel {
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, --narrow-side;
}
Below by default. No room below, go above. Still nothing, take the named one and sit off to the side at a narrower width. And a custom fallback can change other properties too, not only the placement. The panel can be narrower, or scroll, or drop its little arrow when it has to move sideways. I have written that imperatively and it was miserable.
There’s a position-visibility property too, which hides the panel when its anchor scrolls out of view. That’s the detail every hand-rolled tooltip in the world gets wrong. You scroll the container, the trigger disappears, and the tooltip is left hovering over some unrelated paragraph like it lives there.
One gotcha before you go reading anything else about this. Both of those properties got renamed partway through last year, position-area from inset-area and position-try-fallbacks from position-try-options. So more or less every tutorial, demo and Stack Overflow answer written before the fall uses names that no longer do anything, and an unrecognized property in CSS fails by sitting there quietly. Great.
What came out
The component I rebuilt over the holidays lost a positioning library, a resize observer, scroll listeners on two different ancestors, about 90 lines of measurement, and a requestAnimationFrame loop that only existed because one of the observers fired too often.
All of it replaced by 4 declarations that get evaluated as part of layout instead of after it, which is also why it doesn’t lag on scroll the way my measured version always very slightly did.
Stack that on top of popover from last spring and a dropdown menu is now a button with an attribute, a div with an attribute, and 4 lines of CSS. No JavaScript at all, with focus handling that’s correct, stacking that’s correct, light dismiss, and a panel that flips when it runs out of room.
That is a component I have written badly 6 or 7 times.
The part where I ruin it
One engine, since last spring. The other two have it in various states of in-progress and neither has released it, which puts this in the same drawer as :has() was in 2022, except worse, because the fallback story here is genuinely bad.
Most of these features degrade politely. Subgrid falls back to cards whose rows don’t line up. :has() falls back to the modifier class you were already writing. You lose the nice thing and you keep a page.
Here, if anchor() isn’t supported then those declarations are invalid, so they get dropped, and what’s left is position: absolute with no offsets at all. The panel goes to its static position, which is roughly wherever it happened to fall in source order, on top of whatever was already sitting there.
And if it’s a popover it’s funnier than that. The UA stylesheet centers a popover in the viewport, so in a browser without anchor positioning your little filter dropdown opens as a box floating dead center over the page like a modal nobody asked for. Which is a great way to find out you never tested in Safari, on a staging URL, in front of a client. Not that I’d know 😐
So you write a sane non-anchored default first and put the anchored version behind @supports (anchor-name: --x). Which works, and is also the original problem wearing a hat, because the sane non-anchored default for a panel that goes full width on mobile is easy, and the sane non-anchored default for a dropdown next to a button in a crowded toolbar is precisely the thing you were trying to stop hand-rolling.
So: personal work yes, client work no. The library stays on anything I’m getting paid for. I expect to be deleting it some time in 2026.
Good things, slowly
What I keep turning over about this particular pile of features, the popover, the dialog, the top layer, the anchoring, is that they aren’t 4 independent conveniences that happened to land near each other. They were designed together, on purpose, to solve one problem properly. The top layer exists because the stacking bugs were unfixable any other way. The anchoring reaches into the top layer because otherwise the top layer would have been useless for menus. Every piece assumes the others are there.
That took about 5 years, which felt interminable while it was happening. I complained about it here more than once.
I’ve come around on it, mostly. The stuff I grew up with came out fast and didn’t compose. Four vendor prefixes, three properties that almost did the same job, and then a decade of everybody writing the identical workaround anyway. I’d take 5 years and a design that fits together over 18 months and 4 things that don’t. It’s just very hard to hold onto that in year 4, when you’re writing the workaround for the seventh time, and the spec is right there. It’s perfect, and you can’t use it.