z-index feels simple until it fails in a real interface.
You set z-index: 999, reload the page, and the element still sits underneath something else. Or a tooltip refuses to rise above a header. Or a pseudo-element with z-index: -1 disappears in a way that makes no sense at first glance.
Most of those bugs come from the same misunderstanding:
z-indexdoes not create a global ranking for the whole page. It only compares layers inside the stacking context that the element belongs to.
Once that rule clicks, most "random" overlap bugs stop feeling random.
This guide focuses on the failure modes developers hit most often:
z-indexapplied to an element that cannot use it yet- a parent stacking context trapping a child below another branch of the page
- accidental stacking contexts created by other CSS properties
- negative
z-indexbehaving differently than expected
If you want the broader layout baseline first, start with Modern CSS Layout: History, Axes, Flow, and the Mental Model You Actually Need. If your bug involves sticky headers or panels competing for visual priority, Why position: sticky Is Not Working and How to Fix It is the best companion page.
1. What z-index actually controls
Every element already lives in a three-dimensional painting order:
- inline direction
- block direction
- stacking direction
That third direction is what developers usually describe as "front" and "back." z-index lets you influence that order, but only when the browser is comparing boxes in a stacking context where the value matters.
By default, many elements use z-index: auto, and the browser paints them according to normal painting rules and source order. That is why a later sibling often appears on top when boxes overlap even though nobody explicitly set a layer number.
The first practical takeaway is this:
z-index is a comparison tool, not a magic "bring to front" button.
2. When z-index can actually take effect
The most common reason z-index looks broken is that the element is not participating in stacking the way you think it is.
In normal flow, a plain static element does not become a positioned layer just because you gave it a number:
.card {
z-index: 10;
}
That usually does nothing on its own.
The typical fix is to give the element a non-static positioning mode:
.card {
position: relative;
z-index: 10;
}
There are two important exceptions worth remembering:
- Flex items can use
z-indexeven when they are not explicitly positioned - Grid items can also use
z-indexwithout addingposition: relative
That is why overlap handling in Grid compositions can feel cleaner than older absolute-positioning hacks. If you want the Grid-specific version of controlled overlap, continue with CSS Grid Placement, Overlap, and Alignment: How Items Actually Land Where You Expect.
3. The rule that explains almost every confusing case: stacking contexts
A stacking context is a self-contained layer group.
Inside that group, descendants are compared with each other. But once the browser finishes painting that group, the whole thing is treated as one unit in its parent stacking context.
That is the key reason huge z-index values can still lose.
Imagine this structure:
<header class="header">Header</header>
<main class="main">
<div class="tooltip">Tooltip</div>
</main>
.header {
position: relative;
z-index: 2;
}
.main {
position: relative;
z-index: 1;
}
.tooltip {
position: absolute;
z-index: 999;
}
At first glance, 999 looks like it should win.
It does not.
The reason is that .tooltip is not competing directly with .header. Its parent branch, .main, is competing with .header, and .main already lost with z-index: 1.
So the real comparison is:
.headerat level2- the entire
.mainstacking context at level1
Inside .main, the tooltip can absolutely rise above other descendants. It just cannot escape its parent branch and outrank a sibling stacking context that is already above it.
4. Failure mode one: z-index on a static element does nothing
This is the first thing to check when an element refuses to move in front of a sibling.
.badge {
z-index: 99;
}
If .badge is a normal static box, the z-index value alone is often irrelevant.
The smallest safe fix is usually:
.badge {
position: relative;
z-index: 99;
}
That is a good default because position: relative creates the needed positioning behavior without forcing offsets like top or left.
If the element is already a Flex item or Grid item, this particular fix may not be necessary. In that case, look for one of the next failure modes instead.
5. Failure mode two: a parent stacking context is trapping the child
This is the bug behind most "I already used a bigger number" complaints.
Consider a modal trigger or tooltip inside a container that already created its own stacking context:
.shell {
position: relative;
z-index: 1;
}
.popover {
position: absolute;
z-index: 9999;
}
If another sibling branch of the page sits above .shell, the popover is stuck below it no matter how large the child's value becomes.
The fixes are structural, not numeric:
- remove the unnecessary
z-indexfrom the ancestor that is trapping the child - move the overlay to a higher DOM branch
- or raise the ancestor stacking context itself above the competing sibling
The first option is often the cleanest. If a wrapper does not need a z-index, do not give it one just because it "might help later." Extra stacking contexts make later debugging harder.
6. Failure mode three: another property created a stacking context without you noticing
Sometimes the problem is not the z-index declaration at all. The problem is that another property quietly created a new stacking context, changing which elements can compare with which.
Common triggers include:
position: relativeorposition: absolutewith a non-autoz-indexposition: fixedposition: stickyopacityless than1transformother thannonefilterorbackdrop-filtermix-blend-modeother thannormalisolation: isolatecontain: layoutorcontain: paintwill-changewhen it prepares one of those stacking-context-creating properties
This is why a harmless-looking visual tweak can suddenly change layering:
.card {
transform: translateY(-2px);
}
That may solve a hover effect, but it also creates a new stacking context. If the card contains an overlay, badge, pseudo-element, or dropdown, the layering rules can change immediately.
Two practical habits help a lot here:
- Check whether an ancestor recently gained
transform,opacity,filter, or an explicitz-index - Create stacking contexts on purpose, not by accident
If your goal is simply to isolate one card or panel so its children stack safely inside it, isolation: isolate is often the most honest tool because it says exactly what you mean:
.card {
isolation: isolate;
}
Unlike properties such as opacity or transform, it does not also change the visual rendering just to get the stacking side effect.
7. Failure mode four: negative z-index cannot escape a parent stacking context
Negative z-index is useful when a decorative pseudo-element should sit behind an element's content:
.card {
position: relative;
}
.card::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
}
That can work well, but only if the surrounding stacking rules allow it.
Developers often expect z-index: -1 to mean "put this behind everything." That is not what it means. It only moves the element backward inside the current stacking context.
So if the parent already created a stacking context, the pseudo-element may end up:
- behind the parent's content but still inside that context
- unable to move beneath outside siblings
- or visually hidden in a way that feels inconsistent
This happens a lot when the parent uses transform for centering:
.card {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
That transform creates a stacking context. If a child pseudo-element uses z-index: -1, it can no longer behave like a background that slips behind the outer world. It is still trapped inside .card's stacking context.
Two good fixes are common:
- move the stacking-context-creating behavior to a wrapper
- or remove the property that created the stacking context if the layout allows it
For example:
<div class="card-wrap">
<div class="card">Card content</div>
</div>
.card-wrap {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.card {
position: relative;
}
.card::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
}
Now the centering logic lives on the wrapper, and the inner .card can manage its decorative layering more predictably.
8. A compact debugging checklist for real projects
When z-index feels broken, run through these questions in order:
- Is the element positioned, or is it a Flex or Grid item that can use
z-indexdirectly? - Is the element inside a stacking context that is already below a competing sibling branch?
- Did an ancestor gain
transform,opacity,filter,contain, or an explicitz-index? - Are you trying to compare two elements that do not belong to the same stacking context?
- Are you expecting a negative
z-indexvalue to escape its parent context?
That checklist usually finds the cause much faster than increasing 999 to 999999.
9. Practical fixes that age well
Some fixes are much more reliable than others.
Good long-term fixes:
- remove unnecessary ancestor
z-indexvalues - use
position: relativeonly where an element genuinely needs layering or an absolute-positioning anchor - use
isolation: isolatewhen you want a deliberate local stacking context - move overlays higher in the DOM when they need to compete with page-level UI
- keep decorative negative-layer pseudo-elements away from parents that already created stacking contexts
Less reliable fixes:
- increasing the number without checking the parent context
- adding random
transformrules just to "force" a layer - creating extra wrappers with arbitrary
z-indexvalues and hoping they cancel out
10. The mental model worth keeping
Most z-index bugs are not really about the number you chose. They are about the layer group the element belongs to.
If you keep one rule from this page, make it this one:
A child can only win inside its own stacking context. It cannot out-rank a sibling branch whose parent is already above it.
Once you debug from that level instead of from the number alone, overlap bugs become much easier to explain and fix.
The best next step is to pair this with Why position: sticky Is Not Working and How to Fix It for positioning-related failures, or CSS Grid Placement, Overlap, and Alignment: How Items Actually Land Where You Expect if your layering problem lives inside Grid rather than general page structure.