Stacking Layers
Every mantle surface that floats above the page stacks on one shared scale. Two rules decide what paints on top:
- The tier decides between kinds of layers. An overlay always paints above a float, no matter which one opened first.
- Ownership decides within a tier. A float composed inside an overlay portals into that overlay's positioner, so it paints above the overlay that owns it. Sibling layers in the same tier and container paint in mount order: the most recently mounted one wins.
The tiers
Two components place themselves differently on purpose:
- Combobox
.Contentrenders in place atz-50: it does not portal, so the dialog primitive's Escape handling can see it. - MultiSelect
.Contentportals into the closestdata-mantle-modal-contentelement — an enclosing overlay's content — else the nearest overlay's positioner, elsedocument.body.
Layer containers
Each overlay renders a positioner: the fixed element that lays out its content. The positioner doubles as the overlay's layer container: floats composed inside the overlay portal into it, after the content, so they paint above the content inside the overlay's own stacking context. The positioner is a styling hook:
Ownership comes from composition, not timing. A float rendered inside Dialog.Content belongs to that dialog and paints above it. A float rendered outside stays at document.body and paints below every overlay, even when it opens later. Before this contract, every layer shared z-50 and mount order alone decided, so a popover that opened late (an onboarding popover after hydration, for example) painted on top of an open full-bleed dialog.
Floats do not register a container of their own. A select opened from inside a popover shares the popover's container and paints above the popover by mount order: the right rule for layers the user opened in sequence.
See the contract work
Every rule above has a live permutation below, and each also has an automated twin in mantle's layering test matrix (layer-container.test.tsx pins DOM placement; layer-container.browser.test.tsx proves paint order with real hit-testing).
A base float never paints above an overlay
The bug shape the overlay tier exists for: an announcement popover opens on its own while a takeover dialog covers its anchor. Open the takeover, then trigger the late announcement from inside it: the announcement stays under the dialog, and it waits on the page when the dialog closes.
Floats inside a dialog paint above it
Each float composed inside the dialog portals into the dialog's positioner, so it opens on top of the dialog: never behind it, never clipped by it.
Pickers inside a sheet place themselves
Combobox.Content renders in place and MultiSelect.Content portals into the sheet's content element, so both open above the sheet and scroll with it.
Floats inside a float stack by mount order
A float does not register a layer container, so a select opened from a popover shares the popover's container and paints above the popover that spawned it.
Overlays stack in mount order
An overlay opened from inside another overlay portals into the outer overlay's positioner and paints above it, backdrop and all. The popover at the end of the chain paints above both.
Toasts clear every tier
A toast fired from inside an overlay renders in sonner's viewport, far above both tiers, so results stay visible no matter what is open.
Placing custom UI on the scale
- Chrome that floats above page content but under overlays (a fixed action bar, a floating panel): use
z-50in the page, the same tier as page chrome. An open dialog covers it, which is almost always correct. - UI that must paint above open overlays: use
z-60and mount it after the overlay, or render it inside the overlay's content so it joins the overlay's layer. Reach for this rarely: above the overlay tier, only toasts and the skip link should live. - Never out-stack toasts. Toasts announce results of actions taken anywhere, including inside overlays.
- Custom portals inside an overlay: pass the overlay's positioner as the portal target (query the
data-slotabove), or compose a mantle float, which resolves it automatically.Dialog.PortalandHoverCard.Portalaccept acontainerprop when you need explicit control. - Do not select a float's portal as a direct
bodychild (body > [data-radix-popper-content-wrapper]). Inside an overlay the float lives in the positioner. Target the float'sdata-slotinstead.
The full rationale, the alternatives considered, and the paint-order test matrix live in the repo's decision doc, decisions/2026-08-11-floating-layer-tiers.md.