You Might Also Like
Stacking Context Visualizer — Free HTML CSS JS Snippet
Stacking Context Visualizer · Animations · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Stacking Context Visualizer — Animated z-index & Nested Stacking Context Demo in a Tilted 3D Panel Stack

z-index is one of the few CSS properties where the number you write is not actually compared globally — it is only ever compared against sibling elements that share the same stacking context, and creating a new stacking context on a parent silently traps every z-index value inside it. This is the classic bug behind "why doesn't z-index: 9999 work," and it cannot be explained with a flat diagram, because the entire problem is about nesting and containment, not magnitude. This snippet uses a tilted pseudo-3D panel stack, a real position: relative toggle, and a plain-language explanation panel that updates live to make the trap visible and mechanically undeniable.
The isometric tilt is cosmetic, the z-index logic underneath is real
The outer .tilt container gets transform: rotateX(24deg) rotateY(-18deg) inside a .perspective parent with perspective: 900px, giving the whole stack a slight 3D lean so overlapping panels read as physically stacked layers rather than flat overlapping rectangles. This tilt is purely cosmetic — it does not participate in the actual z-index computation at all. The real stacking order is computed by the browser exactly as normal CSS z-index and position rules dictate; render3d() only reads the *result* of that computation (which parent should visually be on top) and applies a translateZ() offset along the Z axis to visually separate the two parents in the tilted 3D space, so the animation you see is a direct visualization of a real stacking decision, not a simulated one.
Building the actual bug: a child with z-index 9999 inside a positioned parent
Parent A contains an orange child panel with an inline z-index: 9999 and Parent B contains an indigo child panel with z-index: 2. By default, Parent A itself has no position set (so no stacking context of its own), which means its orange child's z-index: 9999 is evaluated in the same root stacking context as Parent B's z-index value, and — because 9999 genuinely is larger — the orange child correctly renders above Parent B. This first state is intentionally the *unsurprising* case, so the second state's contrast is legible.
Toggling the stacking-context trap
Checking "Give Parent A its own stacking context" sets parentA.style.position = 'relative' and parentA.style.zIndex = state.parentAZ — the textbook minimal combination that creates a new stacking context per the CSS spec (any of position: relative/absolute + a non-auto z-index, or transform, or opacity < 1, or several other properties, all independently trigger the same containment behavior). The moment that happens, the orange child's z-index: 9999 stops being compared to Parent B at all. It is now only ever compared against *other children of Parent A*, and Parent A itself — as a single unit — is what gets compared against Parent B, using Parent A's own z-index (state.parentAZ, a small number from 0-5), not its child's. updateExplain() recomputes which comparison is actually in effect on every state change and writes the correct plain-language explanation and the correct winner into the result box — the same branching logic a browser's own stacking algorithm follows, just narrated in English instead of executed silently.
Why the result can flip counter-intuitively
With the context toggle on and Parent B's z-index set higher than Parent A's, Parent B visually renders above Parent A's entire subtree — including the orange child, whose z-index of 9999 is still sitting right there in the DOM, completely unable to win. This is the exact real-world bug: a developer sees z-index: 9999 in DevTools on an element that still renders behind something with z-index: 2, and the reason is almost always an ancestor further up the tree that quietly created its own stacking context, usually for an unrelated reason like adding a transform for a hover animation. The three sliders (Parent A z-index, Parent B z-index, child z-index) let you reproduce every combination of this bug on demand and watch render3d() and the explanation panel react to each one in real time.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Give this snippet's JavaScript to an AI assistant like Claude and ask it to trace exactly which comparison is active in updateExplain() for each of the four toggle/slider combinations — the branching there mirrors the real browser algorithm and is worth understanding line by line. It's also worth asking which other CSS properties besides position + z-index would trigger the same trap if applied to Parent A instead. Good extensions to request: a third nested grandchild level to show multi-level trapping, a live DevTools-style "computed stacking context" tree readout, or an opacity/transform toggle as an alternate context trigger alongside the existing position + z-index one.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build an animated stacking-context visualizer in plain HTML, CSS, and JavaScript that demonstrates how a CSS stacking context traps a descendant's z-index value, no frameworks or libraries.
Requirements:
- Two sibling "parent" boxes, each containing one visibly distinct colored child panel, arranged inside a container with a slight CSS 3D tilt (perspective + rotateX/rotateY) purely for visual clarity — the tilt must be cosmetic only and must not affect the actual z-index computation.
- Give the first parent's child an inline z-index of 9999 and the second parent's child a small z-index like 2, and by default leave the first parent with no position/stacking-context-triggering property, so the high child z-index correctly wins against the sibling parent's child in the shared root stacking context.
- Add a checkbox that toggles a real stacking-context trigger (position: relative combined with a numeric z-index) on the first parent. When enabled, the first parent's own z-index — not its child's — must be what gets compared against the second parent, demonstrating that the trapped child's 9999 no longer matters outside its own parent.
- Add independent sliders for the first parent's z-index, the second parent's z-index, and the trapped child's z-index, and recompute which element visually renders on top after every change, animating the two parents' relative depth with a CSS transform (e.g. translateZ) so the reordering is visible, not instant.
- Include a live, plain-language explanation panel that states which specific z-index comparison is currently in effect (child vs sibling parent, or parent vs parent) and a result line stating which element actually wins in the current state, both updating in real time as sliders and the checkbox change.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Observe the default state: child z-index 9999 vs Parent B z-index 2The orange child correctly renders above Parent B's indigo panel — 9999 beats 2 in the same stacking context, exactly as you'd expect from the raw numbers.
- 2Check "Give Parent A its own stacking context"Watch the badge next to Parent A change from "no context" to "stacking context," and the explanation panel update to describe that the child's z-index is now trapped.
- 3Lower Parent B's z-index slider below Parent A'sParent A (and its orange child) rises above Parent B in the tilted stack, animating a visible translateZ shift — this still matches intuition since Parent A's own z-index is now higher.
- 4Now raise Parent B's z-index above Parent A's, leaving the child at 9999Parent B rises above Parent A's entire subtree, dragging the orange child down with it — even though the child's z-index is still 9999, printed right there in the panel label.
- 5Read the result box's plain-language explanationIt states explicitly which comparison is in effect (child vs Parent B, or Parent A vs Parent B) and why, based on the exact toggle and slider state you've set.
- 6Toggle the stacking context checkbox off and on repeatedlyCompare the two explanation states side by side to build the core intuition: stacking contexts are not a display effect, they are a containment boundary that reroutes which comparison z-index values actually participate in.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Yes. Move parentAContext, parentAZ, parentBZ, and childZ into component state and bind the parent/child style attributes to it. In React this is plain useState updated in each control's onChange handler with no useEffect required, since translateZ and z-index changes are driven by CSS transitions triggered by state, not a running animation loop. Vue and Angular follow the same pattern with a reactive ref or component property bound to :style or [ngStyle]. The one thing to preserve in any framework port is recomputing the "who is on top" boolean from the same branching logic (compare child vs sibling parent when no context exists, else compare the two parents' own z-index values) rather than hardcoding one comparison.
Any non-auto z-index combined with position: relative, absolute, fixed, or sticky; any transform, filter, or perspective other than none; opacity less than 1; will-change naming any of the above properties; and a handful of others like mix-blend-mode other than normal or isolation: isolate. This snippet demonstrates the position + z-index trigger specifically because it is the one developers reach for constantly, but any of these properties on an ancestor can silently trap a descendant's z-index the same way.
Because z-index values are only ever compared against other elements within the same stacking context — never globally across the whole page. Once Parent A becomes a stacking context, its child is sealed inside it: the child can still out-rank other children of Parent A, but it can no longer be compared directly to Parent B or anything outside Parent A. From that point on, Parent A as a whole (using its own z-index) is the only thing being compared against Parent B, so the child's 9999 becomes irrelevant to who renders on top externally.
Yes, it is the same underlying mechanism. A position: fixed element with a high z-index can still render behind another element if an ancestor of the fixed element has created its own stacking context (very commonly via a transform used for a modal or drawer animation), trapping the fixed element inside a context that itself loses to a sibling. This is why fixed-position tooltips, modals, and dropdowns sometimes need to be rendered via a portal directly under <body> rather than nested deep in a component tree with transformed ancestors.
Either remove the stacking-context-triggering property from the ancestor if it is not needed, or raise the ancestor's own z-index (not the trapped child's) so the ancestor as a whole wins against its sibling, or move the trapped element out of the ancestor entirely using a portal/teleport pattern so it is no longer a descendant of the context that was trapping it. Simply increasing the trapped child's z-index further, as this snippet's slider demonstrates, has no effect once it is sealed inside an ancestor context.