Skip to content
AtomicReps

Put a popover above every z-index without a portal

A lesson from Modern CSS: Features That Replace JavaScript. Play it above, or read it through below.

Level I · CSS that replaces JavaScript
  • Four of the six work on their own. You have to write the other two.

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

    Four worked 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 you turn them off rather than on: choose popover="manual" and both go, in all three engines, which is what choosing that value is for. The popover attribute ships in every browser, but support is recent enough that you should check it against the oldest browsers you support.

    Focus is the behaviour you have to write yourself. Open a <div popover> and focus does not move: a button that had focus before the open still had it afterwards. Put autofocus on one control inside and focus moves to that control. Focus is not trapped either. showModal() makes the rest of the page inert, and showing a popover does not, so Tab can move focus out of an open popover and into the page behind it.

    Implicit ARIA does less than the name suggests: the attribute grants no role at all, ever, and the invoker relationship the guides describe comes from popovertarget rather than from popover.

    Write popover, keep the four behaviours it gives you, and then write the other two: autofocus on the first control that matters, and the role and label the element would have needed anyway. Those two additions are much less code than the component you are deleting.

  • 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 people forget the second one: it sets the box's order inside its context, and it makes the box a context of its own, so the numbers of its descendants are compared only with each other. That is why raising the tooltip's number never helped: the tooltip was never being compared with the bar.

    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 is then painted as one unit at the ancestor's position. 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 z-index number went up every quarter.

    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 as a performance hint, contain: paint on a long list.

    isolation: isolate is the only entry whose purpose is to make a stacking context, so it is the one to choose when you want a component's internal numbers to stay internal. When a z-index stops working, read the box's ancestors, not the box: the declaration that made the stacking context is on one of them, and it is not a z-index declaration.

  • 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 has a parent context, and the whole arrangement is one tree rooted at the document. That is why raising a number could never put a box above everything: a tree has no "above everything" position, only a position relative to a parent.

    The top layer is a separate layer painted above the root of that tree. An element promoted into it forms a stacking context whose parent is the ROOT stacking context, so no document number is compared with it: it paints after the whole document. "A popover has a very high z-index" is the wrong description, because the top layer has no number. Put z-index: 2147483647 on a document box and open a bare popover over it: the popover is the pixel you see, in every engine.

    z-index has no effect on the top layer in either direction. Two open popovers do not order by their integers: with z-index: 9999 on the one shown first and 1 on the one shown second, the second is the pixel you see in all three engines, and reversing only the show order reverses the picture. No document declaration can paint over a top-layer box either, so the z-index constant has no purpose left. Delete it, and the portal that existed to move the node somewhere the constant would work.

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

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

    These four bugs come from two causes, two bugs each: 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, and an inset: 0 overlay that covers a card instead of the viewport.

    The two tooltip bugs come from one rule. The panel and the overlay come from a second rule that is triggered by most of the same declarations, so the change that fixes the first pair leaves the second pair broken.

    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, which then measure their offsets from its padding box instead of the viewport.

    In all three engines, a position: fixed; top: 0; left: 0 tooltip sits at (0, 0) under a plain ancestor and at (120, 200), the ancestor's own offset, when the ancestor adds only a transform. The browser gives no warning, and if the ancestor sits at the page's top-left, the panel looks correct until the page scrolls.

    The trigger lists overlap heavily, which is why the two rules feel like one, but they are not identical: opacity below 1 and isolation: isolate make a stacking context and do NOT make a containing block for fixed descendants. If the box is in the wrong PLACE, check its ancestors for a containing block. If it is in the right place and behind the wrong thing, check them for a stacking context. Use the top layer when you do not want to check the ancestors at all.

  • The backdrop inherits from the element it belongs to.

    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 rule you learned, that ::backdrop neither inherits from nor is inherited by any other element, was true and is still printed on the reference page most people open.

    Code written for the old rule redeclares a value the backdrop would have received anyway, and still works. Code that relies on the old rule breaks: a backdrop that used to get a property's initial value now gets the value the element carries, and its colour changes with no error.

    This inheritance supports the pattern you would have wanted anyway. 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 gets the fallback value.

    One element declares the tokens and both boxes read them, so the backdrop and the sheet cannot end up with different values. 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 the backdrop not inheriting. Both approaches take one line, and only the explicit declaration keeps working after 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. The menu must not close when the click started inside and ended outside, or when the click was on the trigger, because the trigger toggles the menu anyway and the two handlers race. It must not close while a nested menu is open, or in the same tick the menu opened, because otherwise the opening click closes it.

    The listener also has to be removed on unmount, and the Esc handler is a second listener with its own version of the same checks.

    The popover attribute does all of that by default. A bare popover closes on a click outside and on a close request, measured in all three engines in one test page, and the value that turns both off is popover="manual".

    There are three states, not 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 value for tooltips and closes other hints without closing an open menu. Safari does not support hint, and the failure produces no error: 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 state why the platform's dismissal is wrong for this element.

    The deleted listener also called trigger.focus() on close. The platform covers that. When Esc or the trigger closes the menu while focus is inside it, the browser moves focus back to the element that had focus before the menu opened, which is the trigger when the user opened the menu from the keyboard. A click outside does not, because that click already put focus where the user wanted it. Delete the listener. The trade-off: you can no longer intercept a dismissal, and no line is left 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.

    Does your transition run on both of these closes? 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 Chromium, Firefox and WebKit: 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.

    The difference is not lesson 2's discrete-pair rule and it is not the missing-starting-style rule either, both of which you already know. The entry animates in every engine, on the same test page, which tells you the stylesheet is right and the close is the thing that differs.

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

    In Firefox and WebKit the close is instant, so design the popover to be readable and dismissable with no fade. The .confirm-sheet in the figure meets that bar. Rely on the entry animation, which animates in every engine, and treat the exit as a Chromium refinement until Firefox and WebKit run it too.

  • The attribute replaces the z-index constant. It does not replace the stacking model.

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

    What is still yours to manage? Three things were deleted from this component: the portal, the z-index constant and the outside-click listener, and the attribute replaces all three.

    The attribute does not handle the layering in the rest of the page. The sticky bar still sits above the table and below the drawer, and the badge still has to paint above the card. 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 layering of an interface, and with the stacking model you choose each order on purpose instead of raising numbers until the box shows.

    Use isolation: isolate at component boundaries so a component's internal numbers stay internal, keep single-digit numbers inside each boundary, and use the top layer for the things that have to cover the page.

    Find out which comparison the browser is making, and then decide whether the box should be ordered inside that comparison or taken out of it. To order it, use a number inside a context you chose. To take it out, use the top layer.