Tags: web-dev concept

Positioning and Stacking Contexts

Date: 2026-09-27


z-index only compares siblings inside the same stacking context. An element with z-index: 9999 can’t beat a neighbour at z-index: 2 if its ancestor formed a context below that neighbour, and a stray transform or opacity is 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: fixed inside 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 this
  • position: sticky silently doesn’t stick when an ancestor has overflow: hidden or auto (that ancestor becomes the scroll container, and it doesn’t scroll), or when no top is 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) plus isolation on components is the structural fix — Design Tokens
  • An animation that breaks layering. Adding transform or will-change for 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: hidden on an ancestor clips absolutely positioned descendants whatever their z-index. Stacking and clipping are separate mechanisms
  • Third-party widgets (chat, consent banners) shipping z-index: 2147483647 and 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