Stacking context and z-index
How CSS stacking contexts decide what layers on top in your Tailwind UI, and why a bigger z-index sometimes loses.
You ship a modal. It sits at z-50, the dim backdrop behind it covers the page, and everything looks right. Then the design adds a tooltip inside the modal, and the tooltip needs to float above the modal’s content. That seems easy: the modal is z-50, so you give the tooltip z-[100]. You reload, and the tooltip renders behind the backdrop. Confused, you bump it to z-[9999], the number you reach for when you mean absolutely on top of everything. Still behind. By now you’ve spent twenty minutes on a number that, by every instinct you have, should win.
One reframe explains all of this. z-index is not a global “bring to front” dial. It’s a local ordering, scoped to something called a stacking context . The reason 9999 loses is that your tooltip is trapped inside a context that, taken as a whole, sits below the backdrop it needs to clear. No number you type on the tooltip can lift it past that, because the number never leaves the box the tooltip is trapped in.
That shape should feel familiar. In the lesson on position and inset utilities you met the containing-block surprise, where an ancestor silently changed what your top/left offsets resolved against. This is the same move on a different axis: an ancestor silently changes what your z-index competes against. You already have the instinct for an ancestor quietly rewriting the coordinate system. By the end of this lesson you’ll understand the stacking-context model, find the trapping ancestor in seconds instead of an afternoon, and reach for the right fix, whether that’s portaling the element out or scoping it deliberately, rather than escalating numbers. You’ll also get the answer to a question left open earlier: the z-50 on that toast a couple of lessons back is finally explained here.
Two facts before z-index makes sense
Section titled “Two facts before z-index makes sense”Two facts have to be solid before the rest makes sense. They’re short, and almost every “my z-index is broken” bug is really a failure of one of them, so they come first.
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 element and nothing happens, because it is silently ignored. z-index only takes effect on an element that is positioned, meaning relative, absolute, fixed, or sticky. It also works on flex and grid items even when they’re otherwise static, which is a useful thing to know.
This is why, back in the position lesson, every floating thing you built paired a position value with its z-*. The position is what makes the z-index mean anything at all.
<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>Keep this first bug separate from everything else. “I set z-index and nothing moved” is almost always a missing position, and that is a different problem from the trap we’re building toward, so rule it out first every time.
Fact 2: every page is a tree of stacking contexts
Section titled “Fact 2: every page is a tree of stacking contexts”Here is the central idea. A stacking context is a self-contained group of elements: its members are sorted on the z-axis among themselves, and then the entire context is placed as a single unit inside its parent. Think of it as a sealed envelope. Inside the envelope you can shuffle the pages into any order you like, but the envelope as a whole gets filed in one slot in the drawer, and reordering the pages inside never changes which slot the envelope sits in.
The whole page starts with one root context, on the <html> element. Every other context nests inside it, forming a tree.
This is the rule the entire lesson hangs on: z-index values only compete inside the same context. A z-100 living in one context and a z-40 living in another never compare directly. What compares is the two contexts’ own positions inside whatever parent they share. The number on the child is an internal detail of its envelope, with no say in where the envelope gets filed.
That single rule is why 9999 lost, and the next section makes it visible.
First, a quick note on terminology. When something “stacks on top” of something else, what’s literally happening is paint order : the browser paints the lower thing first, then paints the higher thing over it. “Higher on the z-axis” just means “painted later, so it covers,” and we’ll use the two 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, starting simple and then adding one twist. Scrub through it with the slider, reading each caption before you move on.
One context, three siblings. Each box is relative with a z-index. The higher number paints later, so it lands on top. This is the intuitive model you already half-hold, and z-30 wins because nothing complicates the comparison.
Swap two numbers and the order follows. Within a single context, the number is the whole story: bigger is on top. This is the easy case, and the next step is where things get interesting.
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 huge number sorts the child inside its parent’s envelope, but the parent itself only carries its own place in the root, so the sibling at z-40 simply sits beside it for now.
And the big number loses. Slide the sibling over the parent and watch: the child’s z-[999] never left its parent’s envelope. What competes in the root is the parent’s place, which is roughly zero, against the sibling’s z-40. 40 beats 0, so the sibling paints over the whole parent, child and all. This is the opening bug, now visible.
So the browser does a two-step sort, and naming the two steps makes every future bug legible. 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, because it’s the frontmost thing inside its parent. But step two never looked at that number. It only saw the parent’s envelope, which had no z-index of its own, sitting next to a sibling at z-40. The sibling won, and it 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 section is where the whole lesson pays off. Now that you can see the scope rule, the canonical bug stops being mysterious and becomes something you can reason about line by line.
Here’s the setup. A parent <div> gets opacity-95, a barely perceptible fade of the kind a designer adds to soften a card, the kind you would never suspect of anything. Inside it sits a child with relative z-50. Beside the parent, as its sibling, sits a panel with relative z-40. You look at the numbers: 50 is bigger than 40, so the child should sit on top of the sibling. It doesn’t, and the sibling covers it instead.
Walk it against the two-step sort. That harmless-looking opacity-95 created a stacking context on the parent, because opacity less than 1 does that. (The full list of triggers comes next.) So the child’s z-50 is now scoped inside the parent’s envelope. The parent itself participates in the root context with no z-index of its own, which counts as auto and sorts roughly at zero. The sibling at z-40 is also in the root. Step two compares them: the parent’s envelope at about 0 versus the sibling at 40. 40 wins, so the sibling paints over the entire parent envelope, and the child trapped inside goes down with it. The child’s z-50 was never in this comparison; it was busy winning the ordering inside a sealed envelope, while the envelope itself lost the ordering that decided what you see.
The best way to believe this is to cause it yourself. In the playground below, 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 of the sibling, exactly as the numbers promise. Nudge opacity down to 0.99, a change you can barely see, and watch the child snap behind the sibling. Nothing about the numbers changed. The only thing that changed is that the parent became an envelope.
What you’re watching is real CSS, not a trick: the child is a genuine relative z-50 inside the parent, and the sibling a genuine relative z-40 beside it.
At 100% the parent is exactly opacity: 1, which creates no stacking context, so the child’s z-50 competes directly against the sibling’s z-40 and wins, just as you’d expect.
Nudge to 99% and opacity < 1 turns the parent into an envelope: the child’s z-50 is now sealed inside it, the parent itself sits at auto (about 0) in the shared context, and the sibling’s z-40 paints over the whole parent, child and all.
Opacity is the most surprising trigger precisely because it reads as purely cosmetic, so a barely visible change can flip the entire stack.
Now read the same bug as code, and then as its fix. The tabs below share the same scene: the first is the trap, the second is the fix a 2026 developer reaches for first.
<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, so the tooltip’s z-50 is scoped inside it. The wrapper competes in the root at auto (about 0), the panel at z-40 beats it, and the tooltip rides down with its parent. The 50 > 40 you can read in the source compares two numbers that are never in the same 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 as a child of <body>, where it lives in the root context and competes fairly. createPortal is React’s tool for this. You’ll meet it properly in a later chapter; for now, just recognize the shape. This is exactly what shadcn’s Dialog, Popover, and Tooltip do under the hood, which is why they layer correctly across stacking contexts.
One production gotcha is worth flagging while the concept is fresh, because it produces a bug that comes and goes and is easy to misread.
What secretly creates a stacking context
Section titled “What secretly creates a stacking context”You now understand the trap. The remaining skill is recognition: spotting the ancestor that created the envelope. The thing to internalize is that most of the triggers look purely cosmetic, which is exactly why they catch people out, since nobody suspects a fade or a blur of breaking layering. So rather than memorize twelve separate facts, learn the shapes, grouped below.
Always create one, no z-index needed. position: fixed and position: sticky create a stacking context just by existing. This pays off a promise from the position lesson. Remember the warning that a transform or filter on an ancestor can capture a fixed child, pinning it to the ancestor instead of the viewport? Now you know why: that ancestor created a context (and a containing block), and fixed can’t escape it. The same root cause is behind both symptoms.
Positioned with a real z-index. relative or absolute plus a z-index that isn’t auto. Note 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 and not the same as no z-index at all. That distinction has cost people hours.
The cosmetic ones. These are the surprising triggers: opacity less than 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. opacity is the one to watch most, because it hides inside fade transitions and reads as pure styling.
The deliberate, side-effect-free one. isolation: isolate (Tailwind’s isolate utility). This is the helpful trigger, the only one 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 a context; you’ll meet those in the next chapter, so it’s named here in advance. And top-layer elements, such as a native <dialog>, the popover attribute, and fullscreen, escape every stacking context by rendering above the whole page. That last one is the deeper reason portaled and native popovers layer correctly, and it’s the seed of the first fix.
Before moving to the fixes, drill the distinctions that actually cause bugs. Sort each declaration below 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. Instead, 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. The whole skill is to stop reaching for bigger numbers and start walking up the tree. For the harder cases, Chrome DevTools has a Layers panel (Cmd+Shift+P, then “Show Layers”) that visualizes compositing layers , which roughly map to stacking contexts. The correspondence isn’t perfect, but it’s 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 converts into exactly three fixes. They’re ordered by how often a 2026 developer reaches for each, and each comes with a one-line cue for when to use it.
1. Portal to <body>, the default for floating UI. Move the floating element’s DOM node out of the trapping subtree entirely and render it as a direct child of <body> (or a dedicated portal root). There it lives in the root context and competes fairly with everything else. This is what a portal does, and createPortal is React’s API for it. You’ll build one properly in a later chapter, so don’t worry about the mechanics yet. What matters now is that shadcn’s Dialog, Popover, Tooltip, and Dropdown all portal to <body> for exactly this reason. That’s why those components layer above everything without any high z-index, and it’s what you’d reach for when you hand-roll a floating element. Reach for it when the floating thing is a modal, popover, tooltip, dropdown, or toast, anything that should escape its layout entirely.
2. isolation: isolate, to deliberately scope a context. Sometimes you want layering contained. A card with a decorative background layer behind it and a badge above it should keep all that stacking to itself, rather than leaking into the page or interfering with a neighboring card. Add isolate to the card and you’ve created a context on purpose, 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 holding the pair together is the key insight: a portal escapes a context, while isolate creates one on purpose. 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 is to lift the floating 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, such as a fade animation you can’t delete or a transformed card, because moving the child out also removes it from the wrapper that needs it. In that case, portal it (fix 1) or isolate deliberately (fix 2).
There’s one anti-pattern worth stating plainly, because it’s the single most common junior mistake and a reviewer will flag it 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 and never lifts the context as a whole; you’re rearranging pages inside the sealed envelope while the envelope stays in the wrong drawer. If you find yourself typing a four-digit z-index, the diagnosis is wrong. Walk up the tree and find the envelope instead.
When you hit the bug in real work, this decision tree is the reflex to run. Work through it as if you’re looking at a real misbehaving element.
z-index does nothing on a static element. Add relative (or absolute/fixed/sticky) and it’ll start ordering. This is Fact 1, and it’s a different bug from the trap.
If you’re 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, which is exactly what shadcn’s Dialog, Popover, and Tooltip already do under the hood, and what you’d reach for when hand-rolling.
If the trigger has to stay, don’t try to override it with a number. Scope the layering on purpose 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 floating element can move, lift the element above it in source order.
A z-index scale your team can read
Section titled “A z-index scale your team can read”Here’s a smell you’ll find in real codebases: z-12 here, z-300 there, a z-9999 someone added under deadline pressure, all sprinkled across files by different people who never agreed on a scale. Nobody can predict what’s on top of what, and every new overlay starts another round of bumping numbers. The real fix isn’t a clever number, it’s a convention: a small, named tier scale that the whole team shares, so a z-index tells you what kind of thing an element 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: z-50 was never magic, it’s just the overlay tier. And here’s the reframe that ties the lesson together: once your floating UI is portaled, you barely need high z-index at all. Portaling moves the element into the root context, where plain DOM order does most of the work, so the last-rendered overlay sits on top because it was painted last, not because it won a contest of numbers. A tidy tier scale plus portaling is the whole approach: rather than trying to win that contest, 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, which is the right choice for a decorative layer tucked under a card’s content, exactly like the gradient in the isolate example earlier. Pair it with isolate on the parent so that “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. In this exercise you’ll first create the trap, which shows you understand what triggers a context, and 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 scene that’s already broken: a card with opacity-95 wraps a “New” badge at relative z-50, and a neighboring panel at relative z-40 is wrongly covering the badge. The badge ends up hidden behind the panel. 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, and not by typing 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 is one of those topics that clicks hardest when you watch it move. The video below shows the model live in a visual debugger, and the references after it are worth bookmarking for the day a real bug doesn’t give way to the walk-up reflex.
External resources
Section titled “External resources”The authoritative list of every property that creates a context — the reference for the walk-up reflex.
An interactive deep-dive with editable playgrounds on why a bigger number loses to a smaller one.
That’s the last layout trap in this chapter. You started it not knowing why a block is 256 pixels wide; you’re ending it able to explain why a z-9999 loses to a z-40, and, more importantly, able to fix it without reaching for z-9999 again. Every floating element you build from here, and every shadcn component you drop in, rides on the model you just learned.