Stacking contexts and z-index
How CSS stacking contexts decide what layers on top in a Tailwind UI, and why a bigger z-index can lose.
You ship a modal at z-50, its dim backdrop covers the page, and it looks right. Then a tooltip needs to float above the modal’s content. Easy: the modal is z-50, so you give the tooltip z-[100]. It renders behind the backdrop. You bump it to z-[9999], the number you reach for when you mean on top of everything. Still behind. You’ve now lost twenty minutes to a number that, by every instinct you have, should win.
One reframe explains it. z-index is not a global “bring to front” dial. It’s a local ordering, scoped to a stacking context . 9999 loses because the tooltip is trapped inside a context that, as a whole, sits below the backdrop. No number on the tooltip can lift it past that, because the number never leaves the box.
You met this shape on a different axis in the position lesson: an ancestor silently changed what your top/left resolved against. Here an ancestor silently changes what your z-index competes against. Learn the model and you find the trapping ancestor in seconds, then fix it properly, by portaling the element out or scoping it deliberately, instead of escalating numbers.
Two facts before z-index makes sense
Section titled “Two facts before z-index makes sense”Almost every “my z-index is broken” bug is a failure of one of two facts, so start here.
Fact 1: z-index only takes effect on positioned elements
Section titled “Fact 1: z-index only takes effect on positioned elements”Set z-index on a plain block and nothing happens, it’s silently ignored. It takes effect only on a positioned element, meaning relative, absolute, fixed, or sticky, and on flex and grid items even when they’re static.
This is why every floating thing in the position lesson paired a position value with its z-*. The position is what makes the z-index mean anything.
<div class="z-50">Static — z-index is silently ignored, nothing moves.</div>
<div class="relative z-50">Now z-50 orders this above static siblings.</div>Rule this one out first: “I set z-index and nothing moved” is almost always a missing position, a different problem from the trap we’re building toward.
Fact 2: every page is a tree of stacking contexts
Section titled “Fact 2: every page is a tree of stacking contexts”A stacking context is a self-contained group of elements: its members are sorted on the z-axis among themselves, then the whole context is placed as a single unit in its parent. Think of a sealed envelope. You can shuffle the pages inside into any order, but the envelope gets filed in one slot in the drawer, and reordering its pages never changes that slot.
The page starts with one root context on <html>, and every other context nests inside it, forming a tree.
This is the rule the lesson hangs on: z-index values only compete inside the same context. A z-100 in one context and a z-40 in another never compare directly; what compares is the two contexts’ positions inside the parent they share. The number on the child is an internal detail of its envelope, with no say in where the envelope is filed.
That rule is why 9999 lost, and the next section makes it visible.
One note on terminology. When something “stacks on top,” what literally happens is paint order : the browser paints the lower thing first, then the higher thing over it. “Higher on the z-axis” just means “painted later, so it covers,” and we’ll use both phrasings interchangeably.
How elements stack: the paint order, simplified then nested
Section titled “How elements stack: the paint order, simplified then nested”The fastest way to make the scope rule click is to watch it. The sequence below builds the model in four steps, simple at first, then with one twist. Scrub through with the slider, reading each caption as you go.
One context, three siblings. Each box is relative with a z-index. The higher number paints later, so it lands on top: z-30 wins.
Swap two numbers and the order follows. Within a single context, the number is the whole story: bigger is on top.
Now wrap the middle box in a parent that creates its own context. (Why a parent does that is the next section; for now, trust the label.) Give the child a giant z-[999]. That number sorts the child inside its parent’s envelope, but the parent carries only its own place in the root, so the sibling at z-40 sits beside it.
And the big number loses. Slide the sibling over the parent: the child’s z-[999] never left its parent’s envelope. What competes in the root is the parent’s place, roughly zero, against the sibling’s z-40. 40 beats 0, so the sibling paints over the whole parent, child and all.
So the browser does a two-step sort. Step one: inside each context, sort the members by z-index. Step two: treat each context as a single sealed unit and place it among its siblings in the parent. The child’s z-[999] won step one decisively, since it’s the frontmost thing inside its parent. But step two never looked at that number; it saw only the parent’s envelope, which had no z-index of its own, next to a sibling at z-40. The sibling won and took the whole envelope down with it. Every “bigger number loses” bug comes down to this same two-step sort.
Why your z-index “doesn’t work”: the trapped overlay
Section titled “Why your z-index “doesn’t work”: the trapped overlay”This is where the lesson pays off. With the scope rule in hand, the canonical bug stops being mysterious.
The setup: a parent <div> has opacity-95, the kind of barely perceptible fade a designer adds to soften a card. Inside it sits a child with relative z-50; beside it, a sibling panel with relative z-40. Since 50 beats 40, the child should sit on top. It doesn’t, the sibling covers it.
Run it through the two-step sort. That opacity-95 creates a stacking context on the parent (opacity below 1 is a trigger, the full list comes next), so the child’s z-50 is now sealed inside the parent’s envelope. The parent itself sits in the root context with no z-index of its own, which counts as auto, roughly 0. Step two compares the parent’s envelope (~0) against the sibling (40); 40 wins, so the sibling paints over the entire envelope and the trapped child rides down with it. The child’s z-50 never entered this comparison, it won the ordering inside a sealed envelope while the envelope lost the one that decided what you see.
The fastest way to believe it is to cause it. In the playground, drag the parent’s opacity. At 1 the parent creates no context, so the child’s z-50 competes directly in the root and sits on top, as the numbers promise. Nudge to 0.99, a change you can barely see, and the child snaps behind the sibling. The numbers didn’t change; the parent just became an envelope.
This is real CSS, not a trick: a genuine relative z-50 child inside the parent, a genuine relative z-40 sibling beside it. Opacity is the most surprising trigger precisely because it reads as purely cosmetic, so a change you can barely see flips the entire stack.
Now read the same scene as code, first the trap, then the fix a 2026 developer reaches for.
<div className="relative"> <div className="opacity-95"> <span className="absolute top-2 left-2 z-50 rounded bg-blue-600 px-2 py-1 text-white"> Tooltip </span> </div> <div className="absolute inset-0 z-40 bg-amber-200/80">Panel</div></div>opacity-95 on the wrapper creates a stacking context, sealing the tooltip’s z-50 inside it. The wrapper competes in the root at auto (~0), the panel’s z-40 beats it, and the tooltip rides down with its parent. The 50 > 40 you read in the source compares two numbers that never share a context.
function Card() { return ( <div className="relative"> <div className="opacity-95">{/* the fade stays */}</div> <div className="absolute inset-0 z-40 bg-amber-200/80">Panel</div> {createPortal( <span className="fixed z-50 rounded bg-blue-600 px-2 py-1 text-white"> Tooltip </span>, document.body, )} </div> );}The tooltip’s DOM node leaves the trapping subtree and renders under <body>, in the root context, where it competes fairly. createPortal is React’s tool for this (full treatment in a later chapter). It’s exactly what shadcn’s Dialog, Popover, and Tooltip do under the hood, which is why they layer correctly across stacking contexts.
What creates a stacking context
Section titled “What creates a stacking context”You understand the trap; the remaining skill is recognition: spotting the ancestor that created the envelope. Most triggers look purely cosmetic, which is exactly why they catch people out, nobody suspects a fade or a blur of breaking layering. So learn the shapes below rather than memorize twelve separate facts.
Always, no z-index needed. position: fixed and position: sticky create a context just by existing. This pays off the position lesson’s warning that a transform or filter on an ancestor can capture a fixed child, pinning it to the ancestor instead of the viewport: that ancestor created a context, and fixed can’t escape it.
Positioned with a real z-index. relative or absolute plus a z-index that isn’t auto. Mind the trap inside the trap: bare relative does not create a context, but relative z-0 does, because z-0 is a real z-index value, not the absence of one. That distinction has cost people hours.
The cosmetic ones. opacity below 1, any transform other than none, filter and backdrop-filter, mix-blend-mode other than normal, contain: layout/paint/strict, and will-change naming one of those properties. Watch opacity most: it hides inside fade transitions and reads as pure styling.
The deliberate, side-effect-free one. isolation: isolate (Tailwind’s isolate utility), the only trigger whose entire job is to create a context with zero visual change. It’s the modern replacement for the old transform: translateZ(0) hack, and it carries the next section.
Two more, named so they don’t surprise you later. A container-query container (container-type: size/inline-size) also creates one. And top-layer elements, such as a native <dialog>, the popover attribute, and fullscreen, escape every stacking context by rendering above the whole page, the deeper reason portaled and native popovers layer correctly, and the seed of the first fix.
Before the fixes, drill the distinctions that actually cause bugs. Sort each declaration by whether it creates a context.
Sort each declaration by whether it creates a new stacking context on its own. Drag each item into the bucket it belongs to, then press Check.
opacity: 0.99opacity: 1transform: nonetransform: translateX(10px)position: relativeposition: relative; z-index: 0position: fixedposition: stickyisolation: isolatefilter: blur(2px)position: static; z-index: 50Here’s the diagnostic reflex to carry to work. When z-index “doesn’t work,” don’t bump the number. Walk up the DOM from the floating element and find the first ancestor with opacity < 1, a transform, a filter, position: fixed/sticky, or isolate: that ancestor is the trap. For harder cases, Chrome DevTools has a Layers panel (Cmd+Shift+P, then “Show Layers”) that visualizes compositing layers , which roughly map to stacking contexts, enough to spot the trapping ancestor when reading the source isn’t fast enough.
Three ways out of the trap
Section titled “Three ways out of the trap”The model gives you three fixes, ordered by how often a 2026 developer reaches for each. Each comes with a cue for when to use it.
1. Portal to <body> — the default for floating UI. Move the element’s DOM node out of the trapping subtree and render it as a direct child of <body>, where it lives in the root context and competes fairly with everything else. This is what a portal does; you’ll build one with createPortal in a later chapter. shadcn’s Dialog, Popover, Tooltip, and Dropdown all portal to <body>, which is why they layer above everything without any high z-index. Reach for it when the element is a modal, popover, tooltip, dropdown, or toast — anything that should escape its layout.
2. isolation: isolate — to scope a context on purpose. Sometimes you want layering contained. A card with a decorative layer behind it and a badge above it should keep that stacking to itself, not leak onto the page or interfere with a neighboring card. Add isolate and you’ve created a context deliberately, with zero visual side effects.
<article className="relative isolate rounded-xl border p-4"> <div className="absolute inset-0 -z-10 bg-gradient-to-br from-blue-50 to-white" /> <span className="absolute top-2 right-2 z-10 rounded bg-blue-600 px-2 text-white">New</span> <h3>Pro plan</h3></article>This is the inverse of fix 1, and the pair is the whole insight: a portal escapes a context, isolate creates one. Reach for it when a component’s internal layering (badges, decorative layers, overlapping elements) must stay sealed off from the rest of the page.
3. Restructure the DOM. The bluntest fix: move the element above the trapping ancestor in source order, so it’s no longer inside the envelope. Reach for it when the trapping ancestor is incidental and the element is easy to move. Avoid it when the parent’s opacity or transform is load-bearing — a fade you can’t delete, a transformed card — since moving the child out strips it from the wrapper that needs it. Then portal it (fix 1) or isolate instead (fix 2).
One anti-pattern, the most common junior mistake a reviewer flags on sight: z-9999 is not a fix. A trap can’t be solved from inside it. Bumping the number reorders things within the trapped context but never lifts the context itself — you’re shuffling pages inside the sealed envelope while the envelope stays in the wrong drawer. A four-digit z-index means the diagnosis is wrong; walk up the tree and find the envelope.
When you hit this bug for real, run the decision tree.
z-index does nothing on a static element. Add relative (or absolute/fixed/sticky) and it’ll start ordering. This is Fact 1, a different bug from the trap.
In the same context, z-index really is the whole story. Raise it sparingly, on the team scale, or check source order: two elements with the same z-index stack by DOM order, later on top.
Floating UI belongs in the root context. Portal it out — exactly what shadcn’s Dialog, Popover, and Tooltip do under the hood.
If the trigger has to stay, don’t override it with a number. Scope the layering with isolate, or lift the floating element above the trapping ancestor in source order.
If the trigger is accidental, delete it. If it’s there for a reason but the element can move, lift it above the trigger in source order.
A z-index scale your team can read
Section titled “A z-index scale your team can read”A common smell in real codebases: z-12 in one file, z-300 in another, a z-9999 someone added under deadline, none of it on an agreed scale. Nobody can predict what sits on top of what, and every new overlay starts another round of bumping numbers. The fix isn’t a clever number, it’s a convention: a small, named tier scale the whole team shares, so a z-index tells you what kind of element it is, not just an arbitrary height.
Tailwind’s default scale is that convention. Adopt it as your tier set and use it on purpose:
z-0z-10z-20 z-30z-40 z-[100]and up That z-50 row is the toast from the position lesson, finally explained: it was never magic, just the overlay tier. And here’s the reframe: once your floating UI is portaled, you barely need high z-index at all. Portaling moves the element into the root context, where the last-rendered overlay sits on top because it was painted last, not because it won a contest of numbers. A tier scale plus portaling is the whole approach: you arrange things so there’s no contest to begin with.
One note on negative z-index. -z-10 puts a child behind its parent’s own background, the right choice for a decorative layer tucked under a card’s content, like the gradient in the isolate example earlier. Pair it with isolate on the parent so “behind the parent” doesn’t accidentally mean “behind unrelated content on the page.”
Reproduce the bug, then fix it
Section titled “Reproduce the bug, then fix it”Now put it together end to end. You’ll first create the trap, which shows you know what triggers a context, then fix it, which shows you’ve grasped the model. Doing both cements the idea far better than fixing a bug someone else planted.
The starter renders a broken scene: a card with opacity-95 wraps a “New” badge at relative z-50, and a neighboring panel at relative z-40 covers the badge anyway. Match the target, where the badge sits correctly on top, and do it without a giant z-index.
The 'New' badge sits at z-50 and the panel beside it at z-40, so the badge should win — but it's stuck behind the panel. Walk up the tree from the badge, find the ancestor that's quietly creating a stacking context, and get the badge on top to match the target. Do it with a small, deliberate change — not a giant z-index. Several fixes work: lift the badge out of the trapping wrapper, scope the layering on purpose with isolate, or drop the offending trigger if it isn't load-bearing.
If you got the badge on top with a small, deliberate change instead of z-[9999], you’ve understood the model. The whole skill comes down to finding the envelope, then escaping it, scoping it, or moving the element past it.
Going deeper
Section titled “Going deeper”The stacking-context model clicks hardest when you watch it move.