Skip to content
AtomicReps

The number went up every quarter

A lesson from Modern CSS: What The Browser Took Back. Play it above, or read it through below.

  • Four of the six land on their own. The other two are yours to write.

    The common read: a <div popover> behaves like dialog.showModal(), so focus lands inside it, focus is trapped while it is open, and a screen reader announces it as a dialog.

    Four landed and two did not, and the two that did not are the pair you were most likely to skip. Top layer placement and ::backdrop are unconditional: the attribute alone put the menu above a sibling carrying z-index: 2147483647, measured as a pixel and not as a hit-test, and a ::backdrop rule painted across the rest of the viewport in the same frame.

    Light dismiss and the close request are on by default and are an opt-out rather than a gift: choose popover="manual" and both go, in all three engines, which is what choosing that value is for. This ships in every browser. It landed recently enough that it is worth checking against your own support floor.

    Focus is the one that costs you. Open a <div popover> and focus stays exactly where it was, measured on a button that had it a moment earlier and still had it afterwards; put autofocus on one control inside and focus lands there. There is no trap either, as far as the spec text goes: nothing in the sources examined states that a popover holds focus, so hold that as unstated rather than promised.

    Implicit ARIA is thinner than it sounds: the attribute grants no role at all, ever, and the invoker relationship the guides describe rides on popovertarget rather than on popover.

    Write popover, take the four, and then write the two: autofocus on the first control that matters, and the role and label the element would have needed anyway. That is a much shorter list than the component you are deleting, and it is a list rather than a promise.

  • A z-index integer makes a context as well as taking a place in one.

    The common read: A big enough z-index always wins eventually, so the fix for a box painting behind another one is a bigger number on that box.

    A z-index integer on a positioned box does two things at once, and the second one is the one nobody budgets for: it sets the box's order inside its context, and it makes the box a context of its own, so every number underneath it is sealed in. That is why the escalation was never a slow fight the biggest number wins.

    It was a fight the tooltip was not in. Once any ancestor makes a context, the tooltip's integer is compared with its siblings inside that context and with nothing outside it, and the whole subtree then travels as one unit at whatever position the ancestor holds. Raising .seat-map to 201 moved every box under it at once, which is why one line on a container fixed what no line on the tooltip could.

    The number went up every quarter, like rent.

    The reason the number never worked was not that it was too small. It was that somewhere between that box and the root there was

    The trigger list is long and you do not need to memorise it, because the entries share one property: every one of them is a declaration somebody adds for a reason that has nothing to do with paint order. A transform for scroll smoothness. An opacity below one for a fade. A filter for a hover state. will-change for a performance hint.

    contain: paint for a long list. isolation: isolate, which is the only one on the list whose entire purpose is to make the context on purpose, and which is therefore the one to reach for when you want a component's internal numbers to stay internal. When a number stops working, stop reading the box and read its ancestors: the declaration that broke it is on one of them and it does not mention z-index.

  • Paint order is a walk down one tree, and the top layer is not on it.

    The common read: The box written last in the markup paints on top, and a z-index only changes that when two boxes both declare one.

    Paint order is a walk down one tree: within a stacking context the browser paints backgrounds, then non-positioned boxes in markup order, then positioned boxes in markup order, and a number only reorders a box against the boxes it is being compared with. Every context on the page hangs off a parent context, and the whole arrangement is one tree rooted at the document.

    That is a complete account of where any box on your page lands, and it is also the reason the escalation could never be won: a tree has no "above everything" position in it, only a position relative to a parent.

    The top layer is a rung above the root of that tree. An element promoted into it forms a stacking context whose parent is the ROOT stacking context, so it is not competing with the document's numbers, it is beside the whole document. That is why an attribute beats an integer here, and why the beat is not "popover has a very high z-index".

    There is no number. Put z-index: 2147483647 on a document box, which is the largest value the engines keep, and put a bare popover over it: the popover is the pixel you see, in every engine.

    The number is finished here, in both directions. Two popovers open at once do not order by their integers: measured with z-index: 9999 on the one shown first and 1 on the one shown second, the second one is the pixel you see in all three engines, and reversing only the show order reverses the picture.

    Downwards it is just as final, because once a box is in the top layer no declaration anywhere in the document can paint over it, so the constant you were maintaining has no job left. Delete it, and delete the portal that existed to move the node somewhere the constant would work.

  • A transform breaks two different rules, and only one of them is about paint.

    The common read: watch for a transformed ancestor is one rule, so once I know about stacking contexts I have covered every bug a transform causes.

    Two of these four are the same bug and two of them are not. A tooltip painting behind a sticky bar. A tooltip painting behind a modal. A position: fixed panel that scrolls away with the page instead of staying put. An inset: 0 overlay that covers a card instead of the viewport.

    The two tooltips are one rule. The panel and the overlay are a second rule that fires on most of the same declarations, so the change that clears the first pair leaves the second pair exactly where it was.

    Stacking context decides paint ORDER. Containing block decides what an offset is measured FROM. A non-none transform, filter, backdrop-filter, perspective, contain: paint or a will-change naming one of those makes the ancestor the containing block for fixed descendants, and the fixed box then resolves its offsets against that ancestor's padding box rather than against the viewport.

    Measured on the pinned engines: the same position: fixed; top: 0; left: 0 tooltip sits at viewport (0, 0) under a plain ancestor and at (120, 200) under an ancestor whose only added declaration is a transform, which is exactly the ancestor's own offset on the page. Nothing warns, and the panel looks correct until something scrolls.

    The trigger lists overlap heavily, which is why the two rules feel like one, and they are not identical: opacity below 1 and isolation: isolate make a stacking context and do NOT make a containing block for fixed descendants. So the working diagnostic is a question rather than a list. If the box is in the wrong PLACE, walk up looking for a containing block.

    If the box is in the right place and behind the wrong thing, walk up looking for a stacking context. Reach for the top layer when you want to stop walking up at all.

  • The backdrop belongs to the element, and takes its values from it.

    The common read: ::backdrop neither inherits from nor is inherited by anything, so a value set on the popover cannot reach it and a custom property has to be redeclared.

    ::backdrop inherits from its originating element, which is the popover or the dialog it belongs to rather than that element's parent, so anything inheritable you set on the element is already on the backdrop. The sentence you are carrying, that it neither inherits from nor is inherited by any other element, was true and is still printed on the reference page most people open.

    It costs nothing to hold it: code written that way redeclares a value it would have received anyway, and works. It costs something the first time you rely on it, because a backdrop that was quietly falling back to an initial value starts painting the value the element was carrying all along.

    What that buys is the pattern you would have wanted regardless. Put your overlay design tokens on the popover itself, and the backdrop reads them: measured, a custom property set on the popover reaches its backdrop, and a custom property set on an ancestor reaches it too, by inheriting through the popover, while a popover outside that ancestor falls back.

    One element owns the tokens and both boxes read them, so the dimming and the sheet cannot drift apart. Every top-layer element gets its own backdrop, so a dialog and a popover open at once are two independent boxes rather than one shared scrim.

    Do not build a rule that DEPENDS on the backdrop failing to inherit. Set what you want on the element, read it in the backdrop, and if you need the backdrop to differ, declare the difference on the backdrop rather than relying on a gap. That is one line either way and only one of the two survives an engine update.

  • Light dismiss is a default you can turn off, not a feature you add.

    The common read: closing on an outside click is application logic, so it belongs in a listener you own.

    The listener is twenty-nine lines, and the ones that matter are all about when NOT to close. Not when the click started inside and ended outside. Not when it landed on the trigger, because the trigger will toggle it anyway and the two handlers race. Not when a nested menu is open. Not on the same tick the menu opened, or the opening click closes it.

    And it has to come off on unmount, and the Esc handler is a second listener with its own version of the same list.

    All of that is the default. A bare popover closes on a click outside and on a close request, measured in all three engines on the same page, and the value that turns both off is popover="manual". The states are three rather than two: auto is the default and closes other auto popovers when it opens, manual opts out of both dismissals and of that mutual closing, and hint is the tooltip tier that closes other hints without disturbing an open menu.

    Safari is the holdout on hint, and it does not fail loudly: the attribute reflects as manual there, which means a hover card written against hint stops light-dismissing on one engine and stays on screen. Choose auto unless you can say out loud why the platform's dismissal is wrong for this element.

    The one thing to keep from the deleted listener is the part that was never about dismissal. Restoring focus to the trigger on close is real work and the platform does only some of it, so a menu you open from a toolbar still wants that button focused when it closes. Write that, delete the rest, and take the trade: you lose the ability to intercept a dismissal and you delete every line that could race.

  • Leaving the top layer is a style change, and one engine waits for it.

    The common read: an exit transition that works on a class toggle works the same way on a popover close, because it is the same transition on the same property.

    Which of the two closes is the one your transition survives. The same opacity transition, on the same element, driven once by adding a class and once by closing the popover.

    The class toggle animates in all three pinned engines: dozens of sampled opacities, transitionstart and transitionend on opacity, and the box fades. The popover close animates in chromium and in neither of the other two: firefox and webkit report zero transition events, opacity reads 0 and display reads none on the very first sampled frame, and the pixel where the sheet was is the page background 120ms in.

    This is not lesson 2's discrete-pair rule and it is not the missing-starting-style rule either, both of which you already hold. The entry animates everywhere, in the same fixture, in the same run, which is what tells you the stylesheet is right and the close is the thing that differs.

    So write the exit and do not stake anything on it. transition-behavior: allow-discrete on display and overlay in the transition-property list is the correct spelling and it is what makes the chromium exit work. display is the one the fade needs, and overlay is what keeps the box in the top layer while the fade runs: measured, overlay still reads "auto" 123ms into the close with it in the list, and "none" at 126ms without, while the opacity ramps either way.

    Everywhere else the close is instant, so the popover must be readable and dismissable without the fade, which it is. Take the entry animation, which lands in every engine, and treat the exit as a chromium refinement until the other two catch up.

  • The attribute retires the constant. It does not retire the model.

    The common read: popover moves everything above the page, so the stacking rules are somebody else's problem now.

    So what is left for you to own. Three things came out of this component: the portal, the z-index constant and the outside-click listener, and the attribute genuinely replaces all three.

    What it does not replace is the rest of the page. The sticky bar still sits above the table and below the drawer, the badge still has to clear the card, the sheet still dims correctly only because the tokens live on one element.

    Every one of those is a stacking-context decision, and there is no attribute for them because they are not overlays: they are the ordinary depth of an interface, and the model you now hold is what makes them a decision rather than an escalation. Use isolation: isolate at component boundaries so a component's internal numbers stay internal, keep single-digit numbers inside each boundary, and put the top layer under the things that genuinely have to cover the page.

    Take the whole lesson as one sentence: find out which comparison the browser is actually making, and then decide whether you want to win it or leave it. Winning it is a number inside a context you chose. Leaving it is an attribute.