CSS z-index is one of the most misunderstood properties in CSS. Developers increase it to 999 or even 9999 expecting an element to appear on top — and it still gets buried. The reason is almost always the same: stacking contexts. This guide explains how z-index actually works, what stacking contexts are, why they matter, and how to debug and fix layering problems without resorting to arbitrary large numbers.

What z-index does

z-index controls the stacking order of elements along the Z axis — the axis pointing toward and away from the viewer. Elements with a higher z-index appear in front of elements with a lower one.

.behind {
  z-index: 1;
}

.in-front {
  z-index: 2; /* appears on top of .behind */
}

However, z-index only works on positioned elements — elements with a position value of relative, absolute, fixed, or sticky. On elements with position: static (the default), z-index is completely ignored.

/* z-index has NO effect here */
.element {
  position: static; /* default */
  z-index: 100;    /* ignored */
}

/* z-index WORKS here */
.element {
  position: relative;
  z-index: 100; /* applied */
}

Stacking order without z-index

Even without any z-index values set, the browser still has rules for which elements paint on top of which. The default painting order from bottom to top is:

  • Background and borders of the root element
  • Non-positioned elements, in source order (later in HTML = on top)
  • Positioned elements with z-index: auto or z-index: 0, in source order

This means that in a normal document flow, an element that appears later in the HTML will naturally paint on top of an earlier one — no z-index needed. You only need z-index when you need to override that natural order.

/* Without z-index: .box-b paints on top because it comes later in HTML */
<div class="box-a">A</div>
<div class="box-b">B</div>

Stacking contexts — the root of all z-index confusion

A stacking context is an isolated layer in which child elements are stacked relative to each other. Elements inside a stacking context are always stacked and painted as a group — they cannot escape it to sit above or below elements outside their stacking context, regardless of their z-index value.

This is why z-index: 9999 sometimes does nothing. If the element is inside a stacking context that itself sits below another element, no z-index value on the child can lift it out.

/* .child can never appear above .other-element because .parent
   creates a stacking context with z-index: 1, and .other-element
   has z-index: 2 at the same level */

<div class="parent" style="position:relative; z-index:1;">
  <div class="child" style="position:relative; z-index:9999;">
    I am trapped inside parent's stacking context
  </div>
</div>

<div class="other-element" style="position:relative; z-index:2;">
  I will always be above .parent and everything inside it
</div>

Think of stacking contexts like a deck of cards within a box. You can reorder the cards inside the box however you like, but the whole box moves as a unit relative to other boxes.

What creates a new stacking context

This is the list most developers are missing. A new stacking context is created by any element that has:

  • position: relative, absolute, or fixed with a z-index value other than auto
  • position: sticky (always creates one)
  • opacity less than 1
  • transform other than none
  • filter other than none
  • backdrop-filter other than none
  • will-change set to any value that would create a stacking context
  • isolation: isolate
  • mix-blend-mode other than normal
  • contain: layout, paint, or strict
  • The root element (<html>) always creates one

The hidden traps on this list are opacity, transform, and filter. Adding transform: translateZ(0) as a performance hack, or setting opacity: 0.99 for a fade, silently creates a stacking context and can break your layering.

/* These all silently create a stacking context and may break z-index on children */
.parent {
  transform: translateZ(0); /* GPU hack — creates stacking context */
  filter: blur(0);           /* same problem */
  opacity: 0.99;             /* same problem */
}

isolation: isolate — the clean solution

isolation: isolate explicitly creates a stacking context without any visual side effect. It is the cleanest way to contain the stacking behaviour of a component without adding arbitrary z-index values or visual changes.

/* Create a stacking context with no visual change */
.component {
  isolation: isolate;
}

/* Now child z-index values are relative to .component,
   not the root document */
.component .dropdown {
  position: absolute;
  z-index: 10; /* 10 relative to .component, not the page */
}

This is particularly useful when building reusable components. Each component gets its own isolated stacking context, so z-index values inside it never conflict with the rest of the page.

Practical z-index scale

Avoid arbitrary values like z-index: 9999. Use a consistent, documented scale across your project. A common pattern is to define layers in increments:

/* Suggested z-index scale using CSS custom properties */
:root {
  --z-below:   -1;
  --z-base:     0;
  --z-raised:   10;   /* cards, dropdowns */
  --z-dropdown: 100;  /* navigation menus */
  --z-sticky:   200;  /* sticky headers */
  --z-overlay:  300;  /* modal overlays */
  --z-modal:    400;  /* modal dialogs */
  --z-toast:    500;  /* notifications */
  --z-tooltip:  600;  /* tooltips */
}

.sticky-header {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
}

.modal-overlay {
  position: fixed;
  z-index: var(--z-overlay);
}

.modal-dialog {
  position: fixed;
  z-index: var(--z-modal);
}

Common scenarios and fixes

Dropdown menu appears behind another section

/* Problem: .hero has transform applied which creates a stacking context */
.hero {
  transform: translateY(-20px); /* creates stacking context! */
}

.navbar {
  position: sticky;
  top: 0;
  z-index: 100;
}

.navbar .dropdown {
  position: absolute;
  z-index: 200; /* has no effect outside navbar's stacking context */
}

/* Fix option 1: Remove transform from .hero, use margin instead */
.hero {
  margin-top: -20px; /* no stacking context created */
}

/* Fix option 2: Give .hero a z-index lower than .navbar */
.hero {
  transform: translateY(-20px);
  z-index: 1; /* now .navbar at z-index:100 is clearly above */
}

Modal appears behind sticky header

/* Problem: sticky header creates its own stacking context */
.sticky-header {
  position: sticky;
  top: 0;
  z-index: 200;
}

.modal-overlay {
  position: fixed;
  z-index: 9999; /* may still lose to sticky header */
}

/* Fix: ensure modal is not a child of any lower stacking context.
   Render modals directly inside body, not inside page sections. */

A positioned element is hidden behind a non-positioned sibling

/* Problem: later sibling in HTML paints on top by default */
.card {
  position: relative;
  /* no z-index — paints below .later-element by source order */
}

.later-element {
  /* no position — but comes later in HTML */
}

/* Fix: add z-index to the positioned element */
.card {
  position: relative;
  z-index: 1;
}

How to debug z-index problems

When z-index is not working as expected, follow this checklist in order:

  • Check position: Is the element positioned (relative, absolute, fixed, or sticky)? If not, z-index is ignored.
  • Find the stacking context: Inspect the element's ancestors in DevTools. Look for any parent with opacity, transform, filter, or a z-index on a positioned element.
  • Compare at the right level: z-index values only compete within the same stacking context. Compare the stacking context ancestors of both elements, not the elements themselves.
  • Use isolation: Add isolation: isolate to components to contain their internal stacking without side effects.
  • Move the element in the DOM: If a modal is inside a low-stacking-context container, move it to be a direct child of <body>.

Frequently asked questions

Why does z-index: 9999 not work?

Almost always because the element is inside a stacking context that itself sits below another element. No matter how high the child's z-index is, it cannot escape its parent's stacking context. Find the stacking context ancestor — usually a parent with transform, opacity, filter, or a z-index — and fix the layering at that level instead.

Does z-index work without position?

No. z-index only applies to positioned elements — those with position set to relative, absolute, fixed, or sticky. On elements with the default position: static, the browser ignores z-index entirely.

What is a stacking context in CSS?

A stacking context is an isolated group of elements that are stacked and painted together as a unit. Elements inside a stacking context are always rendered together — their z-index values only compete with each other inside that context, never with elements outside it. The root <html> element is always the top-level stacking context.

Can z-index be negative?

Yes. A negative z-index (such as z-index: -1) places the element behind its parent and behind normal document flow. This is commonly used to place a pseudo-element behind its parent without hiding other content. The element must still be positioned for z-index: -1 to work.

Does transform always break z-index?

Not always — but it creates a new stacking context, which changes how z-index works for child elements. The child's z-index becomes relative to the transformed parent rather than the document root. This is often unexpected and causes layering bugs, especially when transform is added as a GPU performance optimisation.

What is the best practice for managing z-index across a project?

Define a named scale using CSS custom properties at the root level — for example --z-dropdown: 100, --z-modal: 400, --z-tooltip: 600. This gives every team member a shared vocabulary and prevents competing values from growing uncontrollably. Use isolation: isolate on self-contained components so their internal stacking never leaks out.

Test CSS animations visually

Use our free CSS Animation Generator to build and preview animations in real time - no manual tweaking needed.

Open Animation Generator →