Most of the time, elements on a page sit politely next to or below each other — they don’t overlap. But the moment you start building dropdown menus, modal dialogs, tooltips, sticky headers, or anything that floats on top of other content, things do overlap. And when two elements occupy the same spot, the browser has to decide which one you actually see in front.
That decision is the stacking order, and z-index is how you take control of it. It’s a small property with a reputation for being mysterious — people set z-index: 9999 and it still doesn’t work, and they have no idea why. By the end of this article you’ll understand exactly how stacking works, why z-index sometimes ignores you, and how to stop fighting it. Let’s get into it.
The third dimension: the z-axis
A web page has two obvious dimensions: horizontal (the x-axis) and vertical (the y-axis). z-index deals with a third, invisible one — the z-axis, which points out of the screen toward you. Think of it as depth, or “how close to your face” an element is.
When elements overlap, the one with the higher position on the z-axis covers the one behind it. A bigger z-index value means “closer to the viewer”, so it wins and shows up on top:
.behind {
z-index: 1;
}
.in-front {
z-index: 2; /* higher number wins — appears on top */
}
That’s the whole mental model: higher number, more on top. The catch is that z-index doesn’t work everywhere, and it doesn’t always behave the way the raw numbers suggest. Those two surprises are what trip people up — so let’s clear them up one at a time.
The first rule: z-index needs positioning
Here’s the gotcha that catches almost everyone the first time. By default, z-index does nothing on a normal, statically positioned element. You can set z-index: 9999 and watch it have zero effect.
For z-index to do anything, the element needs a position value other than the default static — that means relative, absolute, fixed, or sticky:
.box {
z-index: 5; /* ignored — position is still static */
}
.box {
position: relative; /* now z-index is allowed to work */
z-index: 5; /* this actually does something now */
}
So the rule is simple: z-index only applies to positioned elements. If your z-index seems to be doing nothing at all, this is the very first thing to check — nine times out of ten, the element just isn’t positioned.
Always pair z-index with a position
Whenever you write z-index, make sure the same element has a position of relative, absolute, fixed, or sticky. A z-index with no positioning is silently ignored — there’s no error, it just quietly does nothing. Getting in the habit of writing the two together saves you a lot of confused staring at the screen.
If you’re shaky on what position: relative versus absolute versus fixed actually do, that’s a separate topic worth its own read — but for z-index purposes, all you need to know is that any of them switches the element out of static and lets z-index take effect.
What happens with no z-index at all
Before reaching for z-index, it helps to know how the browser stacks things when you don’t set it. Overlapping elements still have an order — z-index just lets you override it.
The default rule, roughly, is source order: elements that appear later in the HTML stack on top of ones that came earlier. So if two positioned boxes overlap and neither has a z-index, the one written further down in the markup wins:
<div class="box first">First</div>
<div class="box second">Second</div>
.box {
position: absolute;
width: 150px;
height: 150px;
}
.first { top: 0; left: 0; background: tomato; }
.second { top: 40px; left: 40px; background: skyblue; }
Here .second overlaps .first and sits on top of it — purely because it comes later in the HTML. No z-index involved. Often this is exactly what you want, and you don’t need z-index at all. You only reach for it when source order isn’t giving you the layering you need.
Setting an explicit stacking order
Now let’s actually use z-index. Suppose you have three overlapping cards and you want a specific one in front, regardless of source order:
.card {
position: absolute;
}
.card-a { z-index: 1; }
.card-b { z-index: 3; } /* this one ends up on top */
.card-c { z-index: 2; }
The browser sorts them by z-index: card-b (3) is frontmost, then card-c (2), then card-a (1) at the back. The actual numbers don’t matter — only their relative order does. z-index: 3 beats z-index: 2, exactly the same as z-index: 300 would beat z-index: 200. There’s no prize for big numbers.
You can also use negative values to push an element behind its neighbours:
.background-shape {
position: absolute;
z-index: -1; /* sits behind the surrounding content */
}
This is handy for decorative shapes or background graphics that should sit underneath the text and not interfere with clicks.
Resist the z-index: 9999 reflex
When a layer won’t come to the front, the tempting fix is to slap on a giant number like z-index: 9999. Sometimes it works, but it’s a band-aid: you end up with an arms race where every new component needs an even bigger number, and nobody knows which value is safe. Most of the time the real problem isn’t the number at all — it’s a stacking context, which we’re about to explain. Fix that, and small, sensible z-index values are all you’ll ever need.
The real source of confusion: stacking contexts
Here’s the concept that explains every baffling z-index bug you’ll ever hit. It’s called a stacking context, and once it clicks, z-index stops feeling random.
A stacking context is a self-contained group of elements that stack together as a unit. The key idea: z-index values are only compared within the same stacking context. An element inside one context can never jump in front of an element in a different, higher context — no matter how huge its z-index is.
Think of it like floors in a building. Inside one floor, you can rearrange the furniture however you like. But a chair on the second floor is always below anything on the third floor, even if you put that chair on a very tall pedestal. The pedestal (your z-index) only counts on its own floor.
The root of the page (the <html> element) is the top-level stacking context. But — and this is the crucial part — lots of common CSS properties quietly create a new stacking context. Some of the usual triggers:
- An element with
positionset (relative/absolute/fixed/sticky) and az-indexvalue other thanauto. position: fixedorposition: sticky(these create one all on their own).- An
opacityless than1. - A
transform,filter,perspective,clip-path, ormaskvalue. will-changenaming one of the above properties.isolation: isolate(more on that below — it’s the deliberate one).
That list is why z-index feels haunted. You set a child’s z-index to 9999, but its parent has opacity: 0.99 or a transform, which trapped the child inside a low-priority stacking context. The child now can’t escape past elements outside that context, no matter what number you give it.
A concrete stacking-context bug
Let’s make that real, because it’s the single most common z-index headache:
<div class="parent">
<div class="modal">I want to be on top of everything!</div>
</div>
<div class="other-stuff">Some other content</div>
.parent {
position: relative;
transform: translateY(0); /* ⚠️ this creates a stacking context */
}
.modal {
position: absolute;
z-index: 9999; /* huge — but trapped inside .parent */
}
.other-stuff {
position: relative;
z-index: 1;
}
You’d expect .modal with its z-index: 9999 to crush .other-stuff with its measly z-index: 1. But it doesn’t. The transform on .parent created a new stacking context, so .modal’s entire z-index range is now confined inside .parent. Compared to the outside world, .parent as a whole stacks at a low level — and .modal is stuck riding along with it.
The fix is almost never a bigger number. Instead you either remove the property that created the unwanted context, or you restructure so the element that needs to be on top isn’t trapped inside a low context — for instance, by moving the modal out to the top level of the page (a common pattern for modals and tooltips).
Creating a stacking context on purpose: isolation
Sometimes you want a new stacking context — to wall off a component so its internal z-index values can’t leak out and clash with the rest of the page. The cleanest, most intentional way to do that is the isolation property:
.widget {
isolation: isolate; /* new stacking context, no side effects */
}
Why is this nicer than the alternatives? You could force a stacking context with opacity: 0.999 or transform: translateZ(0), but those are hacks — they change rendering, sometimes cause blurry text, and signal the wrong intent. isolation: isolate does exactly one thing: it creates a stacking context, with no visual side effects. When you build a reusable component and want its z-index values self-contained, this is the tool.
A practical layering system
In real projects, scattered random z-index numbers become a maintenance nightmare. A simple habit that scales well is to define a small, named set of layers and stick to them — using CSS custom properties makes this readable:
:root {
--z-base: 1;
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 30;
--z-modal: 40;
--z-toast: 50;
}
.dropdown { z-index: var(--z-dropdown); }
.modal { z-index: var(--z-modal); }
.toast { z-index: var(--z-toast); }
Now there’s an obvious, documented hierarchy: toasts beat modals, modals beat overlays, overlays beat sticky headers, and so on. Nobody has to guess whether 47 is bigger than the other magic number three files away. When you need a new layer, you add it to the list in the right spot. This small discipline kills most z-index chaos before it starts.
Keep the gaps comfortable
Notice the layers jump by tens (10, 20, 30…) rather than by ones. That leaves room to slot a new layer between two existing ones later without renumbering everything. It’s the same reason people number old BASIC programs in tens — breathing room for inserts.
How to debug a stubborn z-index
When a layer just won’t go where you want, resist the urge to crank the number. Walk through this short checklist instead:
- Is the element positioned? No
positionother thanstaticmeansz-indexis being ignored. This is the most common cause by far. - Walk up the ancestors. Look at each parent for
transform,filter,opacitybelow 1,position: fixed/sticky, orwill-change. Any of those creates a stacking context that may be trapping your element. - Compare within the same context. Two
z-indexvalues only fight each other if they share a stacking context. If they don’t, the contexts are what’s being compared, not your numbers. - Consider moving the element. For things that must sit above everything — modals, tooltips, dropdowns — the robust fix is often to render them at the top level of the page, outside any trapping context, rather than chasing ever-larger numbers.
Your browser’s developer tools help enormously here: many of them show stacking contexts and let you inspect the computed z-index of any element, which turns a guessing game into a quick diagnosis.
Wrapping up
z-index is small but slippery — and almost all of its mystery comes from one concept, the stacking context. Here’s everything worth remembering:
- The z-axis points out of the screen toward you; a higher
z-indexsits closer to the viewer, so it appears on top. z-indexonly works on positioned elements (relative,absolute,fixed, orsticky) — on astaticelement it does nothing.- With no
z-index, overlapping elements stack by source order (later in the HTML wins). - Only the relative order of
z-indexvalues matters;9999is no stronger than3if there’s nothing else between them. - A stacking context is a self-contained stacking group.
z-indexvalues are only compared inside the same context — an element can’t escape its context no matter how high its number. - Common triggers for a new context:
transform,filter,opacity < 1,position: fixed/sticky, and the deliberateisolation: isolate. - Use a small, named layering system with sensible gaps instead of random numbers, and debug by checking positioning first, then walking up the ancestors for hidden stacking contexts.
Get comfortable with stacking contexts and z-index stops being a guessing game — you’ll spot the trap, fix the right thing, and move on. If you’d like to revisit how positioning and overlapping fit into the bigger picture, it’s worth circling back to the CSS box model and the layout tools you’ve already met like Flexbox and Grid — they decide where things go, while z-index decides who wins when they overlap.