Positioning and Stacking Contexts
Date: 2026-09-27
z-indexonly compares siblings inside the same stacking context. An element withz-index: 9999can’t beat a neighbour atz-index: 2if its ancestor formed a context below that neighbour, and a straytransformoropacityis usually what formed it.
Positioning (position) decides where an element is placed relative to normal flow, its containing block, or the viewport. A stacking context is a self-contained layer in the paint order: its children are stacked against each other, and then the whole context is stacked as a single unit against its siblings.
The five positions
static normal flow. top/left/z-index ignored (the default)
relative normal flow, then nudged. its original space is kept
absolute removed from flow. placed against the nearest
positioned ancestor (anything not static)
fixed removed from flow. placed against the viewport…
…unless an ancestor has transform, filter, perspective,
contain or will-change — then against THAT ancestor ← trap
sticky normal flow until a scroll threshold, then acts fixed
within its nearest scrolling ancestor
Two traps are in that list:
position: fixedinside a transformed ancestor isn’t fixed. A modal rendered inside an animated panel scrolls with the panel. The fix is rendering it elsewhere in the DOM (a portal) — or the native<dialog>, which goes in the top layer outside all of thisposition: stickysilently doesn’t stick when an ancestor hasoverflow: hiddenorauto(that ancestor becomes the scroll container, and it doesn’t scroll), or when notopis set
Stacking contexts as a tree
The model that makes z-index stop being mysterious: stacking is resolved per context, bottom-up, like a tree. An element’s z-index only ranks it among its siblings in the same context.
root context
├── header position: relative; z-index: 1 ← forms a context
│ └── dropdown position: absolute; z-index: 9999
└── main position: relative; z-index: 2 ← forms a context
└── card
the root compares header (1) against main (2) → main wins
the dropdown's 9999 only ranks it INSIDE header
→ the dropdown renders under main, whatever its number
The fix is never a bigger number. Either raise the ancestor that forms the context (header above main), or stop the ancestor forming one.
What creates a context
Not just z-index. The usual unintended culprits come from animation and effects:
/* each of these makes the element a new stacking context */
.a { position: relative; z-index: 0; } /* positioned + any z-index except auto */
.b { position: fixed; } /* fixed and sticky: always */
.c { opacity: 0.99; } /* opacity below 1 */
.d { transform: translateZ(0); } /* any transform, even a no-op */
.e { filter: blur(0); } /* filter, backdrop-filter */
.f { will-change: transform; } /* will-change on those properties */
.g { isolation: isolate; } /* ← the deliberate one */
.h { mix-blend-mode: multiply; } /* blend modes */
/* also: a flex or grid child with a z-index, and contain: paint / layout */isolation: isolate is the tool to reach for on purpose. It forms a context with no other side effect, so a component’s internal z-index values (a badge over an image) can’t leak out and fight the rest of the page.
Paint order within one context
Back to front:
1 the context's own background and border
2 children with negative z-index
3 in-flow blocks
4 floats
5 inline content
6 positioned children with z-index auto or 0, in source order
7 positive z-index children, lowest first
This is why a positioned element with no z-index still paints over a later non-positioned sibling: step 6 is above step 3.
Failure modes
- z-index inflation. 999, 9999, 99999. Each escalation is a context bug that the next person will escalate past. A small token scale (
--z-dropdown,--z-modal,--z-toast) plusisolationon components is the structural fix — Design Tokens - An animation that breaks layering. Adding
transformorwill-changefor performance makes a new context and a new containing block. A fixed child stops being fixed and a dropdown gets clipped — Compositing and Layers - Clipped dropdowns.
overflow: hiddenon an ancestor clips absolutely positioned descendants whatever theirz-index. Stacking and clipping are separate mechanisms - Third-party widgets (chat, consent banners) shipping
z-index: 2147483647and still losing to a transformed ancestor on some pages
Where it connects
- Compositing and Layers — the same properties that form stacking contexts often promote compositor layers. Related, but not the same thing
- Focus Management — a modal that’s visually on top but not in the top layer still needs its focus trapped