← All writing
Craft · · 6 min

Anchor positioning

Sticking a box under a button was never supposed to be this hard. Here's the fallback problem that means I'm not shipping it yet.

CSS

Last April I wrote about the popover attribute, which took a component I’d built four times and turned it into markup, and I noted one gap: it puts the panel in the top layer and does nothing about where the panel goes. A dropdown that should appear beneath its button still needed the same positioning library everyone’s been using.

That gap is closing. It’s in one browser and it’s the last piece of a thing I’ve been watching assemble for two years.

Why this was ever hard

Worth spelling out, because “put the box under the button” sounds like it should be two declarations and never has been.

To position a panel relative to a trigger you need the trigger’s position in the viewport, the panel’s own dimensions, the viewport size, and the position of every scrolling ancestor. Then you have to decide what to do when the panel would run off the bottom, so flip it above; off the side, so shift it along.

And then you have to redo all of that whenever anything scrolls, the window resizes, the content changes, or the panel’s own size changes because it loaded something.

That’s the whole reason positioning libraries exist, and they’re good, and they’re also several kilobytes of measurement running on scroll to compute something the layout engine already knows.

Every positioning library is a few kilobytes of JavaScript, measuring on scroll, to compute something the layout engine already knew.

Naming an anchor

The mechanism is that an element declares a name, and another element positions itself against that name.

anchor.css
.filters-button {
  anchor-name: --filters;
}

.filters-panel {
  position: absolute;
  position-anchor: --filters;

  top:  anchor(bottom);      /* my top edge at the anchor's bottom edge */
  left: anchor(left);
  margin-top: 0.5rem;
}
CSS

anchor() resolves to a position on the anchor element, so anchor(bottom) is the anchor’s bottom edge expressed in the coordinate space of the thing being positioned.

There’s a shorthand that covers most cases without four separate offsets:

area.css
.filters-panel {
  position: absolute;
  position-anchor: --filters;
  position-area: block-end span-inline-end;   /* below, aligned to the start edge */
}
CSS

And the crucial bit for the popover work: this crosses the top layer. A popover is rendered outside the document’s normal flow, and it can still anchor to an element inside the document: exactly the combination that made the hand-rolled version fiddly. You could have correct stacking or convenient positioning, and now both.

The flip, declaratively

The genuinely clever part, and the bit that replaces most of the library’s code.

You give it an ordered list of alternative placements, and the browser tries them in order until one fits.

fallbacks.css
.filters-panel {
  position-area: block-end span-inline-end;
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
CSS

Below by default. If there’s no room below, above. If there’s no room to the right, flip the inline axis. And you can define your own named alternatives with a rule if the built-in flips aren’t enough, including changing other properties in the alternative, so a panel can be narrower when it has to sit to the side.

There’s also a property for hiding the panel when the anchor scrolls out of view, which is the small detail every hand-rolled tooltip gets wrong: you scroll the container, the trigger disappears, and the tooltip is left hovering over unrelated content.

What it deletes

On a component I rebuilt over Christmas: a positioning library, a resize observer, a scroll listener on two ancestors, about 90 lines of measurement, and a requestAnimationFrame loop that existed because one of the observers fired too often.

All of it replaced by four declarations that the layout engine evaluates as part of layout, rather than after it, which is also why it doesn’t lag behind during a scroll the way the measured version always slightly did.

Combined with popover from last year, a dropdown menu is now: a button with an attribute, a div with an attribute, and four lines of CSS. No JavaScript at all, with correct focus handling, correct stacking, light dismiss, and sensible positioning that flips when it needs to.

That’s a component I have written, badly, six or seven times.

Support, and the fallback problem

One engine, since last spring. The other two have it in various states of in-progress and neither has shipped.

And the fallback story here is worse than most, and that’s why I’m not putting it on client work yet.

With most of these features the degradation is graceful: subgrid falls back to un-aligned cards, :has() falls back to a modifier class you already have. Here, if anchor() isn’t supported, those declarations are invalid and dropped, and your absolutely positioned panel falls back to being positioned relative to its nearest positioned ancestor, which is wherever it happens to be. That’s not a slightly worse layout, it’s a panel in the wrong place.

So you need a sensible non-anchored default underneath, which for a panel that spans the full width on mobile is easy and for a dropdown next to a button in a toolbar is essentially the problem you were trying to solve.

Designed together

Where I’ve landed for now: use it on personal work, keep the library on client work, and expect to delete the library some time in 2026.

The thing I keep noticing about this particular cluster of features, the popover, the dialog, the top layer, the anchoring, is that they were designed together to solve one problem properly rather than shipped as four independent conveniences. Which is a different quality of platform work from what I grew up with, and it took about five years, and I’d rather have five years and a coherent design than 18 months and four things that don’t compose.

Read similar posts
6 min

Style queries

Five surface variants, four child elements, and 20 selectors that existed only to tell a child which parent it was in. Style queries delete the whole grid of them. That isn't quite the same as saying you needed them.

6 min

currentColor

I opened a button component with 11 color declarations in it, across four variants, and the correct number was one per variant, because six of those properties already default to the thing they were being set to.