Z-Index & Stacking
Z-index and stacking contexts control the visual layering of elements along the Z-axis — the axis coming out of the screen toward the viewer. Every element on a web page exists in a three-dimensional space, and z-index determines which elements appear on top of others when they overlap.
The z-index property seems simple — a higher number means closer to the viewer — but its behavior is governed by stacking contexts, which often cause unexpected layering issues. Mastering z-index requires understanding how stacking contexts are created and how they interact.
| 1 | .box-a { z-index: 1; } /* lower value → further back */ |
| 2 | .box-b { z-index: 10; } /* higher value → closer to viewer */ |
The Z-axis runs perpendicular to the screen surface. Each element occupies a position on this axis determined by:
| 1 | .element { |
| 2 | z-index: 5; /* integer value */ |
| 3 | position: relative; /* z-index only works on positioned elements */ |
| 4 | } |
| 5 | |
| 6 | /* z-index: auto (default) does not create a new stacking context */ |
| 7 | .auto { z-index: auto; } |
| 8 | |
| 9 | /* z-index accepts negative values */ |
| 10 | .behind { z-index: -1; } |
note
The z-index property accepts integer values (positive, zero, negative) or auto. Higher values appear closer to the viewer. Negative values can position elements behind their parent or behind elements that do not have a z-index set.
| 1 | .value-auto { z-index: auto; } /* default — no stacking context created */ |
| 2 | .value-0 { z-index: 0; } /* creates stacking context when positioned */ |
| 3 | .value-1 { z-index: 1; } |
| 4 | .value-999 { z-index: 999; } |
| 5 | .value-neg { z-index: -1; } /* behind parent content */ |
| 6 | |
| 7 | /* Practical range: -10 to 2147483647 */ |
| 8 | /* But nothing over 999 is really necessary if you manage contexts */ |
A stacking context is a group of elements that share a common parent and are painted together. The z-index of elements inside one stacking context only applies relative to other elements in the same context. When comparing elements from different stacking contexts, the parent context's z-index determines the order — not the child's individual z-index.
| 1 | .parent-a { |
| 2 | position: relative; |
| 3 | z-index: 1; /* creates stacking context */ |
| 4 | } |
| 5 | |
| 6 | .parent-b { |
| 7 | position: relative; |
| 8 | z-index: 2; /* creates stacking context — rendered on top of parent-a */ |
| 9 | } |
| 10 | |
| 11 | /* Even though child-of-b has z-index: 1, it renders above |
| 12 | child-of-a (z-index: 9999) because parent-b is above parent-a */ |
| 13 | .child-of-a { |
| 14 | position: absolute; |
| 15 | z-index: 9999; |
| 16 | } |
| 17 | |
| 18 | .child-of-b { |
| 19 | position: absolute; |
| 20 | z-index: 1; |
| 21 | } |
warning
A new stacking context is created when any of the following conditions are met:
| Property | Value | Notes |
|---|---|---|
| position | relative, absolute, fixed, sticky | Only when z-index is not auto |
| opacity | less than 1 | e.g. opacity: 0.99 |
| transform | not none | translate, scale, rotate, etc. |
| filter | not none | blur, brightness, contrast, etc. |
| isolation | isolate | Creates context regardless of other properties |
| mix-blend-mode | not normal | multiply, screen, overlay, etc. |
| contain | layout | paint | strict | Newer contexts |
| will-change | opacity, transform, filter | Creates context if value would |
| 1 | .stacking-context-examples { |
| 2 | /* All of these create a new stacking context */ |
| 3 | } |
| 4 | |
| 5 | .context-1 { |
| 6 | position: relative; |
| 7 | z-index: 0; |
| 8 | } |
| 9 | |
| 10 | .context-2 { |
| 11 | opacity: 0.999; |
| 12 | } |
| 13 | |
| 14 | .context-3 { |
| 15 | transform: translateX(0); |
| 16 | } |
| 17 | |
| 18 | .context-4 { |
| 19 | filter: grayscale(0); |
| 20 | } |
| 21 | |
| 22 | .context-5 { |
| 23 | isolation: isolate; /* explicitly creates context */ |
| 24 | } |
| 25 | |
| 26 | .context-6 { |
| 27 | mix-blend-mode: multiply; |
| 28 | } |
best practice
When elements overlap within the same stacking context, the painting order from back to front is:
| 1 | .stacking-demo { |
| 2 | /* Within the same stacking context, higher z-index = closer to viewer */ |
| 3 | } |
| 4 | |
| 5 | .box-a { z-index: 1; } /* bottom */ |
| 6 | .box-b { z-index: 2; } /* middle */ |
| 7 | .box-c { z-index: 3; } /* top */ |
| 8 | |
| 9 | /* Same z-index → source order determines layering */ |
| 10 | .box-d { z-index: 0; } /* rendered first — behind */ |
| 11 | .box-e { z-index: 0; } /* rendered second — on top */ |
Pitfall 1: The high-z-index arms race
Developers keep increasing z-index values to fix layering issues, ending up with values like 99999. This is a symptom of stacking context mismanagement, not a solution.
| 1 | .modal { z-index: 99999; } |
| 2 | .tooltip { z-index: 99998; } |
| 3 | .dropdown { z-index: 99997; } |
| 4 | /* Arms race — fragile, unmaintainable */ |
Pitfall 2: Fixed positioning and transforms
When a position: fixed element has a parent with a transform, the fixed element's containing block changes from the viewport to the transformed parent. This can cause z-index issues because the fixed element now belongs to a different stacking context.
Pitfall 3: opacity and stacking
Setting opacityto anything less than 1 creates a new stacking context. This means even if a child element has a high z-index, it will not appear above elements outside the parent's context.
| 1 | .parent { |
| 2 | opacity: 0.99; /* creates new stacking context — unexpected! */ |
| 3 | } |
| 4 | |
| 5 | .child { |
| 6 | z-index: 100; /* still bounded by parent's context */ |
| 7 | } |
info
Debugging z-index issues requires tracing which stacking context each element belongs to. Modern browser DevTools provide visualization tools for this.
| 1 | // DevTools technique: inspect stacking layers in Chrome |
| 2 | // 1. Open DevTools → Elements panel |
| 3 | // 2. Select the element with z-index issues |
| 4 | // 3. Check the "Layout" pane for "Stacking context" |
| 5 | // 4. Look for "z-index" in the "Styles" pane |
| 6 | |
| 7 | // Programmatic stacking context detection |
| 8 | function getStackingContext(el) { |
| 9 | const contexts = []; |
| 10 | let current = el.parentElement; |
| 11 | while (current) { |
| 12 | const style = getComputedStyle(current); |
| 13 | const zIndex = style.zIndex; |
| 14 | const position = style.position; |
| 15 | const opacity = parseFloat(style.opacity); |
| 16 | const transform = style.transform; |
| 17 | const filter = style.filter; |
| 18 | |
| 19 | if ( |
| 20 | (position !== 'static' && zIndex !== 'auto') || |
| 21 | (opacity < 1) || |
| 22 | (transform !== 'none') || |
| 23 | (filter !== 'none') |
| 24 | ) { |
| 25 | contexts.push({ |
| 26 | element: current, |
| 27 | reason: transform !== 'none' ? 'transform' : |
| 28 | filter !== 'none' ? 'filter' : |
| 29 | opacity < 1 ? 'opacity: ' + opacity : |
| 30 | 'position: ' + position + ', z-index: ' + zIndex |
| 31 | }); |
| 32 | } |
| 33 | current = current.parentElement; |
| 34 | } |
| 35 | return contexts; |
| 36 | } |
best practice