DEV Community

Franklin
Franklin

Posted on

Native Popovers Didn't Replace JS Libraries — They Replaced Glue Code

Structural sibling to Cascade Layers Didn't Replace Specificity — They Replaced Negotiation — same distinction, different layer.

Also available in Español

The Problem

A support ticket comes in with a screenshot: a data grid, dozens of rows, and an action menu — rename, archive, delete — hanging open in the wrong place. Not off by a few pixels. It's anchored against the last row in the table, regardless of which row's own button was clicked. Click row two's menu, row forty's position. Click row twenty's, same result.

Nobody wrote a bug that says "always use the last row." The team had already done the harder, more modern part correctly: native popover attributes instead of a JavaScript-managed dropdown, CSS anchor positioning instead of manually computed coordinates. The menu is exactly where the code told it to be. The code just didn't declare the relationship everyone assumed it contained.

Why the Problem Exists

Before the browser provided a general-purpose top layer for this kind of overlay, an element anchored to something buried inside a scrollable or clipped container had a real problem: it could be cut off, hidden behind something else, or trapped inside a scroll region that didn't match the rest of the page. The common fix was structural. Render the overlay somewhere else, appended to the end of <body> or wherever a shared "portal" container lived, so its position in the DOM no longer matched its position in the interface. JavaScript then handled everything the DOM no longer expressed on its own — showing it, hiding it, computing where it should sit relative to whatever triggered it, keeping that position updated as the page scrolled or resized.

That wasn't a workaround for a mistake. It was a correct response to a browser that genuinely lacked a general-purpose way for this kind of overlay to escape ancestor clipping. The overlay had to live apart from its trigger, because living close to it was the thing causing the problem.

The First Principle

The platform now separates several responsibilities that older overlay implementations had to reconstruct in JavaScript. The popover attribute, paired with popovertarget on a control, gives the browser a declarative relationship between the control and the popover. When opened, the popover enters the browser's top layer, outside the normal clipping and stacking constraints of its ancestors, and that invoker relationship also provides an implicit anchor reference.

CSS anchor positioning handles spatial tethering separately: a positioned element can be placed relative to an anchor without JavaScript calculating coordinates.

The important boundary appears when a component needs an explicit anchor rather than the popover's own invoking control. Explicit anchor names are not automatically scoped to a component's DOM subtree. When multiple anchors expose the same name, an anchor-positioned element can resolve against the last matching anchor in source order.

anchor-scope changes that relationship by explicitly restricting where a named anchor can be resolved.

The browser can therefore own the overlay boundary and the positioning mechanics without removing architectural responsibility. HTML establishes the interaction relationship. CSS establishes the spatial relationship and its scope. The remaining question is whether the DOM actually declares the boundary the component depends on.

Demonstrating the Principle

Reduce it to two repeated items, each with an anchor and a positioned sibling:

<div class="item-wrapper">
  <div class="item">
    <div class="thumbnail"></div>
  </div>
  <div class="badge" popover></div>
</div>

<div class="item-wrapper">
  <div class="item">
    <div class="thumbnail"></div>
  </div>
  <div class="badge" popover></div>
</div>
Enter fullscreen mode Exit fullscreen mode
.thumbnail {
  anchor-name: --thumb;
}

.badge {
  position-anchor: --thumb;
  position-area: top right;
}
Enter fullscreen mode Exit fullscreen mode

.thumbnail and .badge are siblings inside each .item-wrapper. They share the same explicit anchor name, but that name is not automatically limited to the wrapper's subtree. Without an anchor scope, the repeated --thumb names remain visible to positioned elements elsewhere in the document. When multiple anchors share that name, the positioned element resolves against the last matching anchor in source order.

.item-wrapper {

  anchor-scope: --thumb;

}
Enter fullscreen mode Exit fullscreen mode

One line establishes the missing boundary. Each .badge can now resolve --thumb only within its own .item-wrapper, so the identical anchor name is no longer ambiguous across the repeated components.

The important part isn't that the browser suddenly learned what a component is. The important part is that the component finally declared the boundary the anchor relationship depended on.

The Pain Point

This surfaced in a production data grid: repeating rows, each with a trailing action button opening a menu. The menu needed to align to the row's own right edge, consistently, regardless of exactly where the button sat inside the row. That made the platform's simplest option insufficient: a popover's implicit anchor follows its own invoking control, but the control here was a small button, not the row itself. Anchoring to the row meant naming it explicitly, which was a reasonable, deliberate call. Popovers associated with an invoker receive an implicit anchor reference to that invoker; an explicit anchor association is a separate mechanism.

What wasn't reconsidered was where the menu itself lived in the markup. Before the team adopted the native popover attribute, their overlay components had always rendered as a sibling to whatever triggered them, one level up in the DOM — a structural habit from when an overlay nested near a row risked being clipped by that row's own overflow: hidden. The native attribute's top-layer behavior made that risk disappear. The sibling placement never got revisited, because nothing about switching to popover seemed to require it. Open popovers are placed in the top layer and aren't influenced by ancestor overflow or position styling.

Every row shared the same anchor name, set through the same class every row already used for its layout. No row's menu sat inside the row it belonged to, and the explicit anchor name wasn't scoped to that row's subtree.

With that boundary absent, the repeated anchor name remained visible across the grid. Resolution fell through to the last matching anchor in the document. Every menu, on every row, pointed at the same place, because the DOM had no declared scope for the relationship.

The fix didn't touch the anchor name or the button. It wrapped each row and its own menu in a shared container, the smallest ancestor holding both, and scoped the anchor name to it:

.row-wrapper {

  anchor-scope: --row-anchor;

}
Enter fullscreen mode Exit fullscreen mode

Saturday's Notes from the Pass reproduces the misplaced menu exactly as it shipped, confirms why the browser had nothing to prefer, and shows the scoped fix holding across every row in the grid, not just the one being tested.

The Broader Lesson

This isn't a story about CSS anchor positioning being incomplete. Implicit anchoring covers the ordinary case — a popover tethered to its own trigger — without asking for a single line of naming or scoping. The additional architectural work appears when a real requirement steps outside that case. The platform can establish the overlay relationship, but an explicit anchor relationship still needs an explicit boundary.

Here, the problem wasn't that the platform couldn't position the menu. It was that the DOM retained a structure from the pre-popover implementation without declaring the component boundary the new positioning relationship required.

Article 4 found the same shape in application code: logic that kept managing a responsibility the browser had already taken over. This is that pattern showing up in structure instead of behavior. A DOM shape built to solve a problem doesn't automatically stop existing once the problem it solved goes away. It sits there, correct by habit, until something built on a newer assumption runs straight into it.

Adopting a platform feature isn't only a matter of swapping an attribute in for a script. It's an invitation to ask which parts of the surrounding structure were built to compensate for a limitation that attribute just removed, and which of those parts still need to be there, now declared on purpose instead of inherited by accident.

Top comments (0)