CSS z-index (sometimes called z-order) 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, how it interacts with position, what stacking contexts are, how flexbox, grid, Bootstrap, Tailwind, and React change the picture, 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 */
}

z-index and position: how they work together

Since z-index only affects positioned elements, the position value you choose changes how the stacking actually behaves. This pairing is often what people mean when they search for css z position: not two separate topics, but the fact that z-index needs a position value to take effect, and each value produces a different result.

position valueHow z-index behaves
relativeStacks the element within its normal flow position. Commonly used to turn an element into a positioning anchor for absolutely positioned children.
absoluteRemoves the element from flow and stacks it relative to the nearest positioned ancestor. The most common pairing for dropdowns, tooltips, and badges.
fixedStacks relative to the viewport itself and always creates a new stacking context, which is why fixed headers and modals need deliberate z-index planning.
stickyBehaves like relative until it sticks, then like fixed. Always creates its own stacking context, even with z-index left at auto.

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.

z-index in flexbox and grid layouts

There's one exception to the "z-index needs position" rule that catches a lot of developers off guard: flex items and grid items. Any direct child of a display: flex or display: grid container can use z-index even with the default position: static, no positioning required.

/* z-index works here even though .card has no position set,
   because it is a direct child of a flex container */
.card-row {
  display: flex;
}

.card-row .card {
  z-index: 2; /* works without position: relative */
}

This is defined in the flexbox and grid specifications, and modern browsers implement it consistently: the flex or grid item is painted as if it had position: relative, so its z-index competes directly with its sibling items inside the same container.

If z-index appears to do nothing inside a flex or grid layout, the more likely cause is that the item's parent container is trapped inside a lower stacking context, not that the item needs position added.

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);
}

Quick reference: z-index for common UI components

These ranges line up with the custom-property scale above, so every component pulls from the same shared vocabulary instead of competing arbitrary numbers.

ComponentTypical rangeTip
Sticky header / navbar100-200Use position: sticky or fixed plus a mid-range value so page content never covers it.
Dropdown menu150-300Must sit above the navbar that contains it; check the navbar isn't trapped inside a transformed ancestor.
Modal overlay (backdrop)300-400Render as a direct child of body so no page section can trap it in a lower stacking context.
Modal dialog400-500Keep it one step above its own overlay so the dialog paints in front of the dimmed backdrop.
Toast / cookie banner500-600Should usually outrank modals so a notification is never hidden behind a dialog.
Tooltip600-700Give it the highest value on the page since it must appear above modals and toasts alike.

z-index in frameworks: Bootstrap, Tailwind, React, and WordPress

The rules above apply everywhere, but each framework adds its own defaults and conventions worth knowing.

Bootstrap

Bootstrap ships with a built-in z-index scale for its overlay components (dropdowns, sticky elements, offcanvas panels, modal backdrops and dialogs, popovers, tooltips, and toasts), defined as Sass variables that climb from roughly 1000 up to 1090. If a custom dropdown or modal keeps disappearing behind Bootstrap's own components, check whether your custom z-index falls inside that range rather than reaching for an arbitrary large number.

Tailwind CSS

Tailwind provides z-0, z-10, z-20, z-30, z-40, z-50, and z-auto out of the box. For a one-off value outside that scale, use the arbitrary value syntax: z-[999]. As with plain CSS, none of these utilities do anything unless the element also has a position utility such as absolute, relative, fixed, or sticky, or is a flex or grid child.

/* Tailwind: position utility + z utility */
<div class="relative z-10">...</div>

/* One-off value with arbitrary value syntax */
<div class="absolute z-[999]">...</div>

React and React Native

In a plain React web app, z-index works exactly as it does in regular CSS, since React just renders standard DOM elements. Most "z-index not working in React" reports trace back to a component library rendering its modal, tooltip, or dropdown through a portal into a different part of the DOM tree, which changes which stacking context the element actually lives in. Check where the library renders its portal before reaching for a higher z-index value.

React Native is a different story: zIndex is fully supported on iOS, but has historically had inconsistent support on Android, where the elevation prop (which also adds a shadow) is often needed alongside zIndex for reliable stacking.

WordPress and page builders

Elementor, Divi, and similar builders often wrap widgets in several extra containers, and those wrappers frequently pick up a transform or opacity value from an animation or entrance-effect setting. That silently creates a stacking context around the widget, so raising the widget's own z-index does nothing. Check the wrapping container's animation and effects settings before adjusting z-index directly.

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.

Does z-index work on flexbox or grid items without position?

Yes. Flex items and grid items are a specific exception to the position rule: any direct child of a display: flex or display: grid container can use z-index even with the default position: static. The browser paints it as if it had position: relative, so its z-index competes with its sibling items in the same container.

Why is z-index not working in Bootstrap or Tailwind?

In Bootstrap, custom z-index values often lose to the framework's own scale for dropdowns, modals, and tooltips, which spans roughly 1000 to 1090, so pick a value outside that range or match it deliberately. In Tailwind, the z-* utilities still require a position utility such as relative or absolute on the same element, exactly like plain CSS; add a position class alongside the z- class, or use bracket syntax like z-[999] for one-off values.

Why does z-index fail in React or React Native apps?

In a React web app, z-index behaves like normal CSS since React just renders DOM elements. Failures are usually caused by a UI library rendering its modal or tooltip through a portal into a different part of the page, which changes which stacking context it lives in. In React Native, zIndex works reliably on iOS but has inconsistent support on Android, where pairing it with the elevation prop is often needed.

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 →