CSS z-index: Controlling Which Element Sits on Top

z-index decides which overlapping element appears in front. Learn how stacking works, why z-index sometimes does nothing, what a stacking context is, and how to fix the bugs it causes.

Published August 1, 202611 min readBy ACY Partner Indonesia
CSS z-index — controlling the stacking order of overlapping elements
300 × 250Ad Space AvailablePlace your ad here

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 position set (relative/absolute/fixed/sticky) and a z-index value other than auto.
  • position: fixed or position: sticky (these create one all on their own).
  • An opacity less than 1.
  • A transform, filter, perspective, clip-path, or mask value.
  • will-change naming 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:

  1. Is the element positioned? No position other than static means z-index is being ignored. This is the most common cause by far.
  2. Walk up the ancestors. Look at each parent for transform, filter, opacity below 1, position: fixed/sticky, or will-change. Any of those creates a stacking context that may be trapping your element.
  3. Compare within the same context. Two z-index values only fight each other if they share a stacking context. If they don’t, the contexts are what’s being compared, not your numbers.
  4. 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-index sits closer to the viewer, so it appears on top.
  • z-index only works on positioned elements (relative, absolute, fixed, or sticky) — on a static element it does nothing.
  • With no z-index, overlapping elements stack by source order (later in the HTML wins).
  • Only the relative order of z-index values matters; 9999 is no stronger than 3 if there’s nothing else between them.
  • A stacking context is a self-contained stacking group. z-index values 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 deliberate isolation: 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.

Tags:cssfrontendz-indexstacking contextlayoutintermediate
728 × 90Ad Space AvailablePlace your ad here

Related Articles

See All Articles

You Might Also Like

Frontend best practices overview cover
Frontend / Fundamentals

Frontend Best Practices: An Overview

A friendly map of what good frontend really means — semantic HTML, clean CSS, unobtrusive JavaScript, responsive layouts, performance, and progressive enhancement, with pointers to go deeper.

Sep 20, 20268 min read
A Frontend Developer Roadmap cover with the path HTML to CSS to JavaScript
Frontend / Fundamentals

A Frontend Developer Roadmap: What to Learn, and in What Order

A calm, step-by-step learning path for beginners: HTML, CSS, JavaScript, developer tools, responsive and accessible design, a framework, TypeScript, and deployment, with the reasoning behind each step.

Sep 20, 202611 min read
Title card reading Styling and Interactivity over a dark blue ACY Partner background
Frontend / Fundamentals

How Styling and Interactivity Work

A beginner-friendly look at how CSS turns plain HTML into a designed layout, and how JavaScript adds behavior — the structure, style, behavior mental model for building web pages.

Sep 20, 20269 min read
Three front-end layers combining on one web page
Frontend / Fundamentals

How HTML, CSS, and JavaScript Work Together

A hands-on walkthrough for beginners: build one small button, give it structure with HTML, looks with CSS, and behavior with JavaScript, and watch the three layers cooperate on a real page.

Sep 20, 20268 min read
The Frontend Developer Toolkit cover with editor, browser, and CLI motifs
Frontend / Fundamentals

The Frontend Developer Toolkit

A friendly tour of the everyday tools a frontend developer relies on — code editor, browser and DevTools, terminal, version control, package managers, and a dev server — and what each one is actually for.

Sep 20, 202610 min read
Cover illustration for What Frontend Developers Do
Frontend / Fundamentals

What Frontend Developers Do: A Beginner's Guide to the Role

A clear, beginner-friendly look at what frontend developers actually do every day: turning designs into working interfaces, building reusable UI, and caring about responsiveness, accessibility, and speed.

Sep 20, 202610 min read