Z-index & Stacking Contexts

CSS · Lesson 22 of 55 9 min read Updated

Sooner or later every web developer writes z-index: 9999 and watches it do nothing. The dropdown still hides behind the next card, or a decoration placed behind a heading disappears. The number is never the problem: the browser follows layering rules you have not met yet.

In this lesson you will learn the browser's painting order, what z-index really does, what creates a stacking context, how to use isolation and the top layer, and a checklist for fixing "z-index not working". You should know the position values from Positioning first.

How the browser layers a page

Think of the screen as a pile of paper. The browser paints from the bottom of the pile to the top, so whatever is painted last appears in front. Inside one group (a stacking context, explained below), the order from back to front is:

  1. The background and border of the element that owns the group.
  2. Positioned elements with a negative z-index.
  3. Normal block boxes in the flow, in HTML order.
  4. Floated boxes.
  5. Inline content: text, images, inline-blocks.
  6. Positioned elements with z-index: auto or 0, in HTML order.
  7. Positioned elements with a positive z-index, lowest number first.

Two rules explain most of what you see:

  • A positioned element (any position except static) paints above normal, non-positioned elements, even if it comes earlier in the HTML.
  • When two elements are at the same level, the one later in the HTML paints on top.
HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Painting order</title>
  <style>
    body { font-family: system-ui, sans-serif; padding: 1.5rem; }
    .tile { width: 12rem; height: 6rem; display: grid; place-content: center; color: white; font-weight: 600; border-radius: 10px; }
    .tile + .tile { margin-top: -2rem; }
    .a { background: #1d4ed8; position: relative; }       /* positioned, first in HTML */
    .b { background: #047857; margin-left: 2rem; }        /* normal, not positioned */
    .c { background: #b91c1c; margin-left: 4rem; }        /* normal, not positioned */
  </style>
</head>
<body>
  <div class="tile a">A: positioned</div>
  <div class="tile b">B: static</div>
  <div class="tile c">C: static</div>
</body>
</html>

C covers the bottom of B because it comes later in the HTML. But A covers the top of B, even though A comes first, because A is positioned. Now add position: relative to .b and run it again: B is positioned too and comes later than A, so it covers A, and it also rises above C, which is still static.

What z-index does

z-index moves an element up or down within its group. Higher numbers are closer to you. It works on:

  • positioned elements (relative, absolute, fixed, sticky);
  • flex items and grid items, even without position.

On a normal block with position: static that is not a flex or grid item, z-index does nothing at all. That is the first reason it "does not work".

The default value is auto, which means "no special layer, stay in the normal painting order". A negative value sends a positioned element behind the normal content of its group, which is useful for decorations.

Stacking contexts

A stacking context is a group of layers that the browser paints as one unit. Imagine a sealed envelope full of paper sheets: you can reorder the sheets inside the envelope as much as you like, but the whole envelope still sits at one place in the pile. A sheet inside an envelope can never come above another envelope that sits higher.

That is why a child with z-index: 9999 can stay behind something with z-index: 2: its number is only compared with its siblings inside the same envelope.

These properties create a new stacking context:

PropertyCreates a stacking context when
position: relative or absolutez-index is not auto
position: fixed or stickyalways
flex or grid itemz-index is not auto
opacityless than 1
transform, translate, rotate, scalenot none
filter, backdrop-filter, clip-path, masknot none
mix-blend-modenot normal
isolationisolate
will-changenames one of the properties above
containlayout, paint, strict or content
container-typesize or inline-size

The <html> element is always the outermost stacking context. Notice how many of these are visual effects: a card that fades in or lifts on hover becomes its own group without you asking.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Trapped dropdown</title>
  <style>
    body { font-family: system-ui, sans-serif; padding: 1rem; }
    .cards { display: grid; gap: 0.75rem; max-width: 20rem; }
    .card {
      padding: 1rem;
      background: white;
      border: 1px solid #cbd5e1;
      border-radius: 10px;
      animation: rise 0.5s ease-out both;   /* the final translate creates a stacking context */
    }
    @keyframes rise {
      from { opacity: 0; translate: 0 1rem; }
      to   { opacity: 1; translate: 0 0; }
    }
    .actions { position: relative; }
    .menu {
      position: absolute;
      top: 1.8rem;
      left: 0;
      z-index: 999;
      margin: 0;
      padding: 0.5rem 1rem;
      list-style: none;
      background: #fef3c7;
      border: 1px solid #f59e0b;
      border-radius: 8px;
    }
    /* The fix: raise the whole card that holds the open menu */
    /* .card:first-child { z-index: 1; } */
  </style>
</head>
<body>
  <div class="cards">
    <article class="card">
      <strong>Order #1042</strong>, Aarav, ₹1,299
      <div class="actions">
        <span>Options:</span>
        <ul class="menu"><li>Track</li><li>Return</li><li>Download invoice</li></ul>
      </div>
    </article>
    <article class="card"><strong>Order #1043</strong>, Priya, ₹649</article>
  </div>
</body>
</html>

The yellow menu has z-index: 999, yet the second card covers it. Each card became a stacking context because the animation leaves translate applied, so the menu is sealed inside the first card, and the second card is painted later. Remove the comment marks around the last rule and run it again. The card is a grid item, so z-index: 1 works without position, and the whole first card now sits above the second. In a real app you would raise the card whose menu is open, for example with .card:focus-within.

isolation: isolate

Sometimes you want a new stacking context. isolation: isolate creates one and does nothing else: no visual change, no positioning.

A classic use is a decoration placed behind an element's text with z-index: -1. Without a stacking context on the parent, the decoration falls behind the parent's own background (or even the page background) and vanishes. With isolation: isolate on the parent, -1 means "behind my text, but still inside my box".

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>isolation</title>
  <style>
    body { font-family: system-ui, sans-serif; padding: 1.5rem; }
    .panel { background: #f1f5f9; padding: 1.5rem; margin-bottom: 1rem; border-radius: 12px; }
    .highlight { position: relative; display: inline-block; font-size: 2rem; margin: 0; }
    .highlight::before {
      content: "";
      position: absolute;
      inset: 55% -0.25rem 0.1rem;
      background: #fde047;
      z-index: -1;               /* behind the text */
      border-radius: 4px;
    }
    .fixed { isolation: isolate; }
  </style>
</head>
<body>
  <div class="panel"><h2 class="highlight">Admissions open</h2> (no isolation: the yellow bar is lost behind the grey panel)</div>
  <div class="panel fixed"><h2 class="highlight">Admissions open</h2> (isolation: isolate on the panel)</div>
</body>
</html>

In the first panel, the yellow bar went all the way back, behind the grey panel background. In the second, the panel is a stacking context, so the bar stays inside it, between the panel's background and the heading's text.

isolation: isolate is also a good habit on reusable components: their internal z-index values cannot leak out and fight with the rest of the page.

The top layer

Modal dialogs and popovers do not need z-index at all. A <dialog> opened with showModal(), an element using the popover attribute, and an element in full-screen mode are placed in the top layer: a special layer above the whole page, above every stacking context, whatever its z-index. See Details, Summary & Dialog and Popover API.

Debugging "z-index not working"

Work through these questions in order:

  1. Can this element take a z-index? It must be positioned, or a flex or grid item. Otherwise the value is ignored.
  2. Which stacking context is it in? Walk up through its ancestors in the browser's developer tools. Look for opacity below 1, transform, translate, filter, will-change, isolation, sticky or fixed positioning, or position with a z-index.
  3. Compare the right elements. Find the two ancestors that sit side by side in the same stacking context. Their order decides the result, not the children's numbers.
  4. Is it hidden, not covered? An ancestor with overflow: hidden or clip-path cuts off a dropdown. That looks like a layering problem, but no z-index can fix it.
  5. Should it be in the top layer? Use <dialog> or popover for modals, menus and toasts.

Microsoft Edge's developer tools include a 3D View with a Z-index tab that draws the stacking contexts of a page in 3D, which helps on big pages.

Keep your values small and planned. Many teams store them as custom properties:

CSS
:root {
  --z-dropdown: 10;
  --z-sticky-header: 20;
  --z-toast: 30;
}
.site-header { position: sticky; top: 0; z-index: var(--z-sticky-header); }
.menu        { position: absolute; z-index: var(--z-dropdown); }

Common mistakes

  • z-index on a static element. It is ignored unless the element is positioned or a flex or grid item.
  • Raising the child instead of the ancestor. A child cannot escape its stacking context; raise the ancestor that forms it.
  • Forgetting that effects create contexts. opacity, transform, filter and will-change all create stacking contexts.
  • z-index: -1 that disappears. Give the parent isolation: isolate so the element stays inside it.

Key points

  • Within a group, positioned elements paint above normal ones, and later HTML paints above earlier HTML.
  • z-index works on positioned elements and on flex and grid items; higher numbers come forward.
  • A stacking context is a sealed group: children are only ordered inside it.
  • opacity, transforms, filters, isolation, fixed and sticky positioning and more create stacking contexts.
  • isolation: isolate creates a context on purpose, which keeps z-index: -1 decorations and component layers contained.
  • Dialogs and popovers use the top layer, above everything, without any z-index.

Exercise

A product page for a Kochi spice shop has a sticky header with z-index: 20 and a grid of product cards that have filter: drop-shadow(...) applied. Each card has a "Share" menu with position: absolute; z-index: 50 that is hidden behind the next card. Write CSS that fixes the menus while keeping the header above the cards when the page scrolls. Then add a heading with a light-orange highlight behind its text using ::before and z-index: -1.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Spice shop</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 0; }
    header { position: sticky; top: 0; z-index: 20; background: #14532d; color: white; padding: 0.75rem 1rem; }
    .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: 1rem; padding: 1rem; }
    .card { background: white; padding: 1rem; border-radius: 10px; filter: drop-shadow(0 2px 4px rgb(0 0 0 / 25%)); }
    .card:focus-within, .card:hover { z-index: 1; }   /* raise the card whose menu is in use */
    .share { position: relative; }
    .share ul { position: absolute; top: 100%; left: 0; z-index: 50; margin: 0; padding: 0.5rem 1rem; list-style: none; background: #fff7ed; border: 1px solid #fb923c; }
    .title { position: relative; isolation: isolate; width: fit-content; margin: 1rem; }
    .title::before { content: ""; position: absolute; inset: 50% -0.3rem 0; background: #fed7aa; z-index: -1; }
  </style>
</head>
<body>
  <header>Kochi Spice Market</header>
  <h1 class="title">Fresh from Kerala</h1>
  <div class="grid">
    <article class="card">Black pepper, ₹320 <div class="share"><button>Share</button><ul><li>WhatsApp</li><li>Email</li></ul></div></article>
    <article class="card">Green cardamom, ₹450</article>
  </div>
</body>
</html>

The cards are grid items, so z-index: 1 raises the active card's whole stacking context above its neighbours, but it stays below the header's 20 because both are compared in the root context. The heading isolates itself, so its -1 highlight stays behind its own text and not behind the page.

Quick Quiz

1. A <div> with position: static has z-index: 10. What happens?

2. Which declaration does NOT create a stacking context?

3. A menu with z-index: 999 is covered by the next card. Both cards have opacity: 0.95. What is the best fix?

4. Why does <dialog> opened with showModal() appear above everything without z-index?

Learn CSS with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call