Stacking
One z-index scale for every floating surface — and why an overlay opened inside a Modal or Drawer needs no z-index at all.
Overlays paint in the order this page describes, and there is nothing to set up — no provider, no zIndex props, no !important anywhere in the chain. Read on only if you need to retune the order, or you're staring at a panel that's gone behind something.
The four bands
Every floating surface belongs to one of four bands, each a CSS variable on :root. Higher paints later, so it wins:
- --tooltip-z: 200Tooltip
over anything hoverable, an open picker included
- --modal-z: 150Modal, Drawer+ --layer-index
backdrop + positioner
- --toast-z: 100Notifier region
above page overlays, below dialogs
- --overlay-z: 50Popover, Menu, HoverCard, Select, Combobox, DatePicker+ --layer-index
- your page content — z-index: auto
:root {
--overlay-z: 50;
--toast-z: 100;
--modal-z: 150;
--tooltip-z: 200;
}Two of those choices are worth spelling out:
Tooltipis on top because it can appear over anything hoverable — including an already-open picker.- Toasts sit above page overlays but below dialogs, so a
Modalcovers an arriving toast rather than the other way round.
Bands alone aren't enough though, because overlays nest. Two different mechanisms handle that, and which one applies depends on whether you crossed a band.
Nesting in the same band
A Select opened inside a Popover — the shape of a DataTable column filter — is two panels from the same band, so their base numbers tie. Depth breaks the tie:
- Popover50 + 0 = 50
- Select, opened inside it50 + 1 = 51
- DatePicker, opened inside that50 + 2 = 52
--layer-index on each dismissable layer as it opens, so the newest panel is always the top one — no tier per component type.z-index: calc(var(--overlay-z, 50) + var(--layer-index, 0));The result is that no component type needs its own tier. A picker doesn't outrank a popover because it's a picker; it outranks it because it opened later, on top of it.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
27 | 28 | 29 | 30 | 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
Code
<Popover.Root width="auto">
<Popover.Trigger asChild><Button>Filter campus</Button></Popover.Trigger>
<Popover.Content>
<Popover.Body>
<Select data={operators} label="Operator" />
<DatePicker label="Since" />
</Popover.Body>
</Popover.Content>
</Popover.Root>Nesting across bands
Now the harder case: a Menu opened from inside a Drawer. The menu is in the overlay band (50), the drawer in the modal band (150), so depth can't save it — 50 + 1 is still nowhere near 150.
Raising the overlay band above the modal band wouldn't fix it either; it would just break the reverse case, a Modal opened from a Popover. No fixed pair of numbers is correct in both nesting directions.
So this case isn't solved with numbers at all. Modal and Drawer publish their own positioner as the portal target for anything opened inside them, which makes the panel a descendant of a surface that already outranks the page:
Portaled to <body>
- <body>
- drawer positionerz 150
- menu positionerz 50behind ✗
Siblings, so only the number decides — and no fixed number is right in both nesting directions.
Portaled into the dialog
- <body>
- drawer positionerz 150
- drawer content
- menu positionerz 51on top ✓
A descendant of a surface that already outranks the page, so it clears the panel whatever its own band says.
Code
<Drawer open={open} onOpenChange={setOpen} title="Account">
<Menu.Root>…</Menu.Root> {/* portals into the Drawer, not <body> */}
<Select data={campuses} />
</Drawer>Code
<Modal open={open} onOpenChange={setOpen} title="New session">
<Select data={campuses} />
<DatePicker label="Starts on" />
</Modal>This is also what lets --toast-z sit between the two bands: a picker inside a dialog never has to out-number a toast, because it isn't competing with one — it's inside the dialog that already covers it.
Override the target with container, or render in place with withinPortal={false}. Both are available on Popover, Menu, Tooltip, HoverCard, Select, Combobox and DatePicker.
One outside click, one layer
Open the date picker above, then click the backdrop. The calendar closes and the modal stays open; a second click closes the modal. A Modal, Drawer or Popover won't dismiss on an outside click while a Popover, Menu, Select, Combobox or DatePicker opened inside it is still open, so a stack unwinds one layer per click. Without this, a stray click would discard the form the user was filling in along with the picker. This works wherever the panel portals to, even with container or withinPortal={false}. Tooltips and hover cards don't count, so a hover preview never costs the backdrop an extra click.
Retuning a band
Set the variable in your own stylesheet. App chrome pinned high, for instance:
:root {
--modal-z: 200; /* clear a site header at 120 */
}Because these are ordinary custom properties, they inherit — so you can scope a whole subtree, which is what you want for a widget embedded in someone else's page:
.embedded-widget {
--overlay-z: 10;
--modal-z: 20;
}Keep the relative order intact (overlay < toast < modal < tooltip) unless you specifically want to invert one of the two choices called out above.
Debugging: where the number actually lives
If you go looking in devtools, the z-index will not be where you expect — and this is the one part worth understanding before you try to override anything.
Ark positions each panel with a positioner part, and @zag-js/popper puts two inline styles on it: isolation: isolate, which makes it always its own stacking context, and z-index: var(--z-index). Then, on the first position pass, popper writes --z-index inline itself, copying the computed z-index of its content child:
- isolation: isolate; always its own stacking context
- z-index: var(--z-index);
- --z-index: 51;popper writes this one inline
copied from content's z-index, on the first position pass
z-index: calc(var(--overlay-z) + var(--layer-index));
The kit declares the scale here. This is the value that ends up deciding cross-portal order.
--z-index on the positioner from a stylesheet has no effect, because popper's inline write replaces it on the first position pass.Two things follow, and they're the reason this kit declares the scale where it does:
- Set
z-indexon content. Popper hoists it onto the positioner for you, and that hoisted value is what competes across portals. - Don't set
--z-indexon the positioner. Popper's own inline write replaces whatever your stylesheet said, the moment the panel positions. Only!importantwould beat it — if you find yourself reaching for that, you're on the wrong element.
One more trap in the same area. data-scope and data-part are emitted on the same element, so a selector needs both compounded — with a space it silently matches nothing:
[data-scope='select'][data-part='content'] { … } /* ✓ compound */
[data-scope='select'] [data-part='content'] { … } /* ✗ matches nothing */That's not hypothetical: it's the bug this scale replaced. Popover, Menu and Tooltip don't render a root DOM node at all, so nothing can ever carry the scope as an ancestor.
Changelog
Overlays manager
Spawn modals and drawers imperatively from anywhere — content, confirm and typed context overlays — with one centralized, type-safe manager.
Tabs
A navigation control on Ark UI's Tabs machine — a row (or column) of triggers, each revealing its own content panel, with a pill that slides to the active trigger. Visually SegmentGroup's twin.