CSS is Awesome

← All posts

discovery

The popover attribute's invisible UA stylesheet

Half of what makes [popover] work is user-agent CSS you can silently destroy with one innocent declaration. Three ways we broke it — a permanently open menu, a menu that refused to be full-width, and the inset trick that un-centers the overlay.

Jerry Hansen · · 4 min

  • css
  • popover
  • cascade
  • ua-styles
  • debugging

The popover attribute feels like magic: add it to an element, point a button at it, and you get top-layer rendering, light dismiss, focus handling — no script. But the magic is only half attribute. The other half is a handful of user-agent stylesheet rules, roughly:

[popover]:not(:popover-open) {
  display: none;
}

[popover] {
  position: fixed;
  inset: 0;
  width: fit-content;
  height: fit-content;
  margin: auto;
}

Those rules are UA-origin, and the cascade has an iron law about origins: any author declaration beats any UA declaration, regardless of specificity. Normally that law protects you. With popovers it means one ordinary-looking line in your own stylesheet can delete a piece of the mechanism — and nothing warns you, because as far as the cascade is concerned you did it on purpose.

I know because we did it three times.

Story one: the menu that was always open

Our dropdown mixin laid the menu out as a column:

.menu {
  display: flex;
  flex-direction: column;
}

display: flex is author-origin. [popover]:not(:popover-open) { display: none } is UA-origin. Author wins — so the "closed" state stopped existing. The menu rendered permanently open, and worse, since the hidden/shown toggle is what promotes a popover's box, it sat in flow, shoving the page content down like it was 2009. We shipped that.

The guard is to restate the toggle at author origin yourself:

.menu[popover] {
  display: none;

  &:popover-open {
    display: flex;
    flex-direction: column;
  }
}

Rule of thumb: never set display on a popover unconditionally. The open state gets your flex/grid; the element itself gets an explicit display: none so the closed state survives your own cascade.

Story two: the full-width menu that hugged its content

We anchored a dropdown to its trigger with CSS anchor positioning — both inline edges pinned to the trigger's edges, which should make the menu exactly the trigger's width. It came out the width of its longest item instead, sitting in the right place but visibly narrower than the control that opened it.

The culprit is [popover] { width: fit-content }. When an element has an explicit width, the width wins and the second inset edge is ignored — that is just how absolute positioning resolves over-constraint. So the UA's fit-content silently beat both anchored edges. The fix is one line, and you will not find it by staring at your own code, because the losing rule is not in your code:

.menu[popover] {
  width: auto; /* the UA's fit-content otherwise beats both inset edges */
}

That line cost an hour. It costs you a paragraph.

Story three: inset: unset, the cheap dropdown trick

The UA rules position: fixed; inset: 0; margin: auto are what center a popover in the viewport — the free modal-ish default. Which means the inverse is also available: unset them, and the popover renders where it would have sat in the flow, but still in the top layer.

.menu[popover] {
  inset: unset;
  margin: 0;
}

Position that relative to a wrapper (or better, anchor it) and you have a dropdown that escapes every overflow: hidden ancestor for free. This one is the same phenomenon as the first two stories, used deliberately: author styles beating UA styles is the API. The line between "broke the popover" and "built a dropdown" is whether you knew which UA rule you were overriding.

The lesson

When a platform feature is implemented partly in UA CSS, your stylesheet is part of its implementation whether you meant it or not. Before styling a [popover], <dialog>, or <details>, read the UA rules that make it work — they're in the HTML spec, or one "expand UA styles" checkbox away in DevTools — and decide for each one: preserve it, restate it, or override it on purpose. The failure mode is never an error. It's a menu that is quietly, permanently open.


We use this in css-is-awesome: cia.dropdown, cia.drawer, and cia.sheet all carry these guards — the &[popover] { display: none } restatement and the width: auto escape — so consumers styling on top of them don't rediscover each story the way we did. The mixins are in the component docs.

← All posts

Theme