Z-index strategy
Prefer DOM order to z-index; where you need it, one global file of sequential layer constants for app-wide layers and local values inside an isolated stacking context. No magic numbers, never mix the two, and know which properties create a stacking context or a clip.
Raw markdown On GitHubreferences/surface/z-index-strategy.md
Taught in
z-index: 9999 is a confession that nobody knows the order. Depth has three tools, in order of preference:
-
No z-index. DOM order is paint order; a later sibling draws over an earlier one. Arrange the markup so source order matches stacking order, and most layers need nothing.
-
One global file for app-wide layers. Navigation, popovers, modals and toasts compete across components, so give them sequential constants in a single file, in order:
let d = 1 export const nav = d++, contextMenu = d++, tooltip = d++, modal = d++, toast = d++Adding a layer means inserting a line in the right place; the order stays readable.
-
Local values inside an isolated context. When a component's parts must overlap against their DOM order (tab order or semantics fix it), wrap them in
isolation: isolateand use 1 and 2 inside. Nothing escapes the wrapper, so local numbers can't collide with global ones.
Don't
- Hard-code magic numbers:
9999, Tailwind'sz-10/z-50sprinkled by feel. One or two escape-hatch constants in the global file at most. - Mix global and local:
modal + 1in a component file either silently equalstoast, or, inside a stacking context, means nothing at all.
What creates what
position: fixedandstickycreate a stacking context, but stay bounded by any ancestor that has one. A fixed element inside a nav withz-indexset competes only inside that nav.- An element that creates a stacking context paints like a positioned element at
z-index: 0, so an unpositioned later sibling paints below it. Give the siblingposition: relative. overflow: hiddendoesn't create a stacking context, and no z-index lets a child out of its clip. An absolutely positioned child escapes the clip only when its containing block is outside the overflow element.transform,filter,will-change: transformandcontain: paintall create a containing block, andisolationdoesn't.
When to apply
Setting up a project's layers; any "the dropdown is under the header" bug; reviewing a diff that adds a z-index (ui-polish-pass has the review line). During a drag, depth is local and three-banded: dragged, just released, the rest (animated-hit-testing).
Gotcha
A transform added for an animation, or will-change: transform added "for performance", quietly creates a stacking context and a containing block. A tooltip that used to escape its card's overflow: hidden is clipped, and a modal inside a transformed parent can't rise above the page. Check depth after adding any animation to a container (depth-and-nesting, performance-discipline).
Sources
- Cheng Lou, chenguini (MIT, HEAD 84fb27d):
docs/z-index-depth-management.md(the three strategies, the don'ts, the stacking and clipping gotchas) anddocs/Animation.md"Pitfalls" (nowill-changeby default). - Related: ui-polish-pass, depth-and-nesting, animated-hit-testing.
surfacez-indexstacking-contextdepthlayerscss