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: autoorz-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, orfixedwith az-indexvalue other thanautoposition: sticky(always creates one)opacityless than1transformother thannonefilterother thannonebackdrop-filterother thannonewill-changeset to any value that would create a stacking contextisolation: isolatemix-blend-modeother thannormalcontain: layout,paint, orstrict- 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, orsticky)? If not,z-indexis ignored. - Find the stacking context: Inspect the element's ancestors in DevTools. Look for any parent with
opacity,transform,filter, or az-indexon a positioned element. - Compare at the right level:
z-indexvalues only compete within the same stacking context. Compare the stacking context ancestors of both elements, not the elements themselves. - Use isolation: Add
isolation: isolateto 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 →