Skip to content
WorkWorkWritingWritingAboutAbout
← All writing

Why your CSS z-index is not working

CSS z-index not working, even at 9999? Learn how stacking contexts, positioning, transforms, portals, and the top layer cause and fix layering bugs.

May 2, 2026·15 min read
  • #css
  • #z-index
  • #stacking-context
  • #css-debugging
  • #frontend

Most z-index bugs aren't about z-index.

You have a modal that won't sit on top of a header. You set z-index: 9999. It still sits behind. You bump it to 99999, sprinkle in some !important, and somewhere around the third try you wonder how something this simple keeps winning.

The number isn't where the bug lives. z-index doesn't sort every element on the page against every other element — its effect is scoped by the stacking context your element belongs to. Pick the wrong context and no number on a descendant can move it into a higher one.

Why your CSS z-index is not working

Start with four checks:

  1. Does z-index apply to the element? It applies to positioned boxes (relative, absolute, fixed, or sticky) and to flex and grid items. On an ordinary position: static box, a numeric z-index has no effect.
  2. Are the overlapping elements in the same stacking context? If not, their numbers are not compared directly. The stacking contexts containing them are compared first.
  3. Did an ancestor create a new stacking context or fixed-position containing block? transform, opacity, filter, contain, and several less obvious properties can change the answer without touching the broken element.
  4. Is the element being clipped rather than stacked underneath? An ancestor's overflow: hidden, overflow: clip, mask, clip path, or paint containment can cut an overlay off. A larger z-index cannot paint outside a clip that applies to it.

If z-index: 9999 changes nothing, stop increasing it. Inspect the element and its ancestors in that order; the rest of this guide shows exactly what to look for and which fix fits each cause.

The mental model most people start with is wrong

The mental model usually goes like this: every element has a z-index, and the highest number wins.

What's actually happening is closer to this. The page is divided into layers. Each layer has its own ordering, and z-index works the way you'd expect inside a single layer — higher number, higher in the stack. Across layers, none of that matters; the layer's position relative to other layers decides who appears on top.

The technical name for these layers is stacking context (opens in a new tab). Every page has at least one root context, formed by the <html> element, and your CSS quietly creates more of them as it goes. When a z-index: 9999 modal sits below a z-index: 5 header, it's almost always because the modal lives in a context whose root sits below the header's. The modal won inside its own context — and lost the one that mattered.

Context map

The browser resolves one scope at a time

ROOTdocument
Headerz-index: 10
Card contextstack level: 0
Modalz-index: 9999
Nested stacking-context hierarchyThe root compares the header with the card. Only inside the card is the modal compared with other card content.ROOTdocumentHeaderz-index: 10Card contextstack level: 0Modalz-index: 9999
At the root, the browser compares header 10 with the transformed card at stack level 0. The header wins.

What creates a new stacking context

This is where most z-index bugs come from. Common stacking-context triggers include:

  • position: fixed or position: sticky
  • position: absolute or position: relative combined with any z-index other than auto
  • A flex or grid item with any z-index other than auto
  • opacity less than 1
  • A transform, scale, rotate, or translate other than none
  • A filter other than none
  • A backdrop-filter other than none
  • A perspective, clip-path, or CSS mask other than none
  • A will-change listing any property that itself creates a stacking context
  • isolation: isolate
  • mix-blend-mode other than normal
  • contain: layout, contain: paint, or any composite like contain: strict or contain: content

There are a few more, but those are the ones you run into in practice.

Notice how innocent some of them look. An opacity: 0.99 on a fade-in, a transform: translateZ(0) someone added in hopes of promoting a composited layer, a will-change: transform on a card meant to feel snappier — every one of those creates a new context. Ordinary descendant boxes are then painted as part of that context. A positioned modal nested under such an ancestor stops competing directly with elements outside it; its z-index ranks inside the ancestor's context, and that whole context might already sit below the element you're trying to beat. Top-layer elements, covered later, are the important exception.

A descendant's z-index can rank it inside its current stacking context; it cannot promote it out of that context.

Why z-index: 9999 still sits behind another element

Almost every app has this pattern: a sticky header, content with cards, and a modal that opens from somewhere inside a card.

<header class="site-header">
  <nav>...</nav>
</header>

<main>
  <article class="card">
    <button>View details</button>
    <div class="modal">
      <p>Modal content</p>
    </div>
  </article>
</main>
.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
}

.card {
  transform: translateY(0); /* added for a hover lift */
}

.modal {
  position: fixed;
  z-index: 9999;
}

The modal is fixed-positioned with z-index: 9999 and the header has z-index: 10. You'd expect the modal on top.

It isn't — the header sits over it.

Try the instinctive fix

Add another 9

z-index: 9999
Sticky header10

Transformed card

root stack level: 0

Modal · fixed · 9999
Header still wins

The culprit is transform: translateY(0) on .card. As the CSS Transforms specification (opens in a new tab) defines it, that property creates a new stacking context on the card and a containing block for its fixed-position descendants. Because the modal is rendered inside the card, it's locked inside that context and is positioned relative to the card instead of the viewport. The card's computed z-index remains auto, but a transformed context with no explicit z-index is painted at stack level 0 in its parent — behind the header's z-index: 10. The modal's 9999 is doing its job; it's just doing it inside a context that's already losing.

The frustrating part is that nothing about .card looks like a z-index issue. The hover animation got added six months ago by someone debugging a flicker, and now it's silently breaking a modal in a different file. The thing that broke it isn't visible at the broken element.

How to fix z-index not working

When a z-index bug shows up, the instinct is to inspect the broken element and start raising its z-index. That's almost never where the fix is.

Check whether z-index applies

Inspect the element's computed position first. A numeric z-index applies to a positioned box, so an ordinary static block usually needs an appropriate position value before z-index can change its stack level. Flex and grid items are the exception: z-index applies to them without changing position.

Don't add position: relative mechanically. Positioning may be correct, but a non-auto z-index on that newly positioned element also creates a stacking context. Make the change because the layout needs it, then verify the resulting context tree.

Find the stacking context the broken element is in

Walk up the DOM tree from the broken element. For each ancestor, use the Computed Styles panel to check whether its resolved values match any trigger from the list above — including position plus z-index, transforms, opacity, filters, masking, will-change, isolation, mix-blend-mode, and contain. The closest ancestor whose computed values create a context is the root of the stacking context your element is trapped in.

This is tedious by hand. The CSS Stacking Context Inspector (opens in a new tab) Chrome extension adds a panel to DevTools that shows every stacking context on the page in a tree view, and tells you which one any given element belongs to. It's the most useful single tool for this kind of debugging.

Compare it against the context of the element that's winning

Once you know your broken element's context, find the context of the element appearing on top. They're usually different — your element won inside its own context, but the other one's context outranks yours.

Check whether an ancestor is clipping the element

If the overlay disappears exactly at a parent's edge, you may have a clipping problem instead of a stacking-order problem. overflow: hidden and overflow: clip do not, by themselves, create stacking contexts, but they can prevent descendants from painting outside the ancestor's clipping boundary. Masks, clip paths, and contain: paint can do the same; contain: paint also creates a stacking context.

No z-index can override clipping. Move the overlay outside the clipping ancestor, remove or narrow the clipping rule, or use a portal or top-layer primitive when the UI is supposed to escape that boundary.

Pick the right fix

Which fix fits depends on what the element is for.

For global overlays that must escape a component — typically modals and many tooltips, dropdowns, and toasts — the right answer is usually to lift the element out of its broken context entirely. Their DOM node belongs near the document root, not necessarily inside whichever component triggered them. In React, that's what createPortal (opens in a new tab) is for:

Modal.tsx
import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(<div className="modal">{children}</div>, document.body);
}

DOM placement

Move the modal, not its number

Header10

Card context

transform: translateY(0)

Account settingscard · fixed · 9999
lab root stands in for document.body
lab root → card (level 0) → modal (9999)Header wins

If the element doesn't need to escape, the fix can be smaller. A transform: translateY(0) or will-change: transform someone added to fix an old animation glitch is often doing nothing useful by the time you find it — delete it, and the stacking context goes with it. The Computed Styles panel will tell you whether the property is doing real work.

Sometimes the parent's context is correct and just needs to outrank another part of the page. A sticky sidebar that should stay above the main content is the classic case. The fix there is on the parent, not the child: bump the parent's z-index until its whole context wins.

When the competing elements are in different stacking contexts, bumping the trapped descendant to a bigger number never works. A larger number can reorder elements inside one context; it cannot move an element out of that context.

Use the 3D view if you're stuck

Microsoft Edge ships a 3D view in DevTools that draws every stacking context as a layer in space. Open DevTools, hit the three-dot menu, then More tools → 3D View. Rotating the page makes it obvious which elements stack where, and which contexts contain which descendants. Chrome has a similar feature in the Layers panel, but it's coarser; for stacking-context debugging, the Edge version is the better tool.

A system that prevents most of this

The bigger leverage is preventing these bugs in the first place — a system instead of magic numbers.

Define your layers as tokens

Every element with a z-index in your codebase belongs to one of a small set of layers. It usually comes out to something like:

  • Base content (default)
  • Sticky elements (headers, sticky sidebars)
  • Dropdowns and popovers
  • Modals and overlays
  • Toast notifications
  • Tooltips (above everything else)

Six layers, maybe seven if there's something specific to your app. Define them as CSS custom properties:

tokens.css
:root {
  --z-base: 0;
  --z-sticky: 100;
  --z-dropdown: 200;
  --z-modal: 300;
  --z-toast: 400;
  --z-tooltip: 500;
}

The gaps of 100 are deliberate. They give you room to slot a new layer in later without renumbering everything — if you need something between modal and toast, 350 slides in without touching the rest.

Global scale

Six named layers, one deliberate order

front ↑
  1. 500
    --z-tooltip
    Tooltips
  2. 400
    --z-toast
    Toasts
  3. 300
    --z-modal
    Modals
  4. 200
    --z-dropdown
    Dropdowns
  5. 100
    --z-sticky
    Sticky UI
  6. 0
    --z-base
    Base content

Use the tokens everywhere a z-index appears:

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

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

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

That's the entire system.

Why this matters more than it looks

Naming z-indexes does a few things at once. Random four-digit numbers stop appearing in CSS, and every value carries intent. Bugs become meaningful — a tooltip showing under a modal stops being a "what number do I try" question and turns into a product question about whether tooltips should outrank modals. And changes are safe: dropping tooltips below toasts is one edit, instead of grepping every CSS file and guessing which numbers belonged to which idea.

Local layers for component internals

The tokens handle the global picture. Components have their own internal stacking — a label over a background, a decoration sliding behind content, a focus ring above the border — and that's a different concern. Don't reach for the global tokens; scope local ones to the component instead.

card.css
.card {
  position: relative;
  isolation: isolate;

  --z-card-bg: 0;
  --z-card-content: 1;
  --z-card-overlay: 2;
}

.card-background {
  position: relative;
  z-index: var(--z-card-bg);
}
.card-content {
  position: relative;
  z-index: var(--z-card-content);
}
.card-overlay {
  position: relative;
  z-index: var(--z-card-overlay);
}

The isolation: isolate line does the work. It creates a new stacking context for the card, so the values inside (0, 1, 2) only compete against each other and can't leak into the global layers. Once a component owns its context, its internal layering is its own business.

Enforce it with a lint rule

A token system that isn't enforced erodes. Someone writes z-index: 50 instead of var(--z-sticky), ships the PR, and a year later you have a hybrid system that's worse than no system at all. Stylelint's declaration-property-value-allowed-list (opens in a new tab) rule keeps things honest:

.stylelintrc.json
{
  "rules": {
    "declaration-property-value-allowed-list": {
      "z-index": ["/^var\\(--z-[a-z0-9-]+\\)$/", "auto", "0", "-1", "1"]
    }
  }
}

The rule allows tokens (any var(--z-*)), auto, and the small integers 0, -1, and 1 — those cover the local cases inside isolated components where naming a token would be overkill. Any other value fails the lint check. If you genuinely need an exception, use a narrowly scoped stylelint-disable-next-line declaration-property-value-allowed-list comment that explains why.

When you don't need z-index at all

A lot of layering problems pretending to be z-index problems are really DOM-order problems. When two positioned elements overlap in the same stacking context and both have z-index: auto, the one that comes later in tree order paints on top, and that's enough for plenty of layering. If a tooltip just needs to sit on top of the button it's anchored to, moving it later in the markup is often the whole fix — no z-index, no new stacking context to manage.

Modern primitives go further. The <dialog> (opens in a new tab) element (used with showModal()) and the Popover API (opens in a new tab) render in the top layer — a special, browser-managed ordered set painted after the document's ordinary stacking-context tree. They're built specifically for things that need to escape ancestor layering and clipping rules: modals, dropdowns, tooltips.

<dialog id="help-dialog">
  <p>This dialog renders in the top layer.</p>
  <button onclick="document.getElementById('help-dialog').close()">
    Close
  </button>
</dialog>

<button onclick="document.getElementById('help-dialog').showModal()">
  Open
</button>

Platform primitive

Open a dialog from inside a transformed parent

Transformed parentstack level: 0

Browser top layer

No z-index required.

This dialog remains nested inside a transformed element in the DOM. The browser promoted it above the ordinary stacking-context tree and made the rest of the page inert.

The dialog is a descendant in the DOM, but a top-layer element in the browser's rendering model.

That dialog appears above the document's ordinary stacking contexts, regardless of their z-index or transformed ancestors. Another element added later to the top layer can still appear above it because top-layer order is browser-managed. You don't need a z-index for the escape.

For most modals, dropdowns, and tooltips you'd build today, this is the right starting point. The token system is for the cases where the platform's primitives don't fit.

Common CSS z-index questions

Why isn't z-index working with position absolute?

position: absolute makes z-index apply, but it does not make the value global. If an ancestor creates a lower stacking context, the absolutely positioned element remains inside it. Compare the ancestors' contexts or move the element to a DOM location where it can compete in the intended context.

Why doesn't z-index: 9999 work?

The number only ranks the element inside its current stacking context. If that whole context sits below another context, 9999, 999999, and every larger value lose for the same reason. Raise the context root, remove the accidental context, or move the overlay out of it.

Does transform affect z-index and position fixed?

Yes. Any computed transform other than none creates a stacking context. It also establishes the containing block for absolute and fixed descendants, so a fixed modal nested inside a transformed card is positioned relative to that card instead of the viewport.

Can overflow hidden override z-index?

It does not override the numeric value. It clips content at the ancestor's boundary, which z-index cannot bypass. overflow: hidden alone does not create a stacking context, so diagnose clipping and stacking as separate mechanisms.

What is the maximum z-index?

CSS defines z-index as an integer, while the supported numeric range is implementation-defined. Designing around a browser's largest accepted number is still the wrong fix: even the maximum value cannot escape a lower stacking context or an ancestor's clip. Use a small, named layer scale instead.

Putting it together

A working setup ends up as five pieces:

  1. Six layers, defined as tokens. Every named layer is a CSS variable; every z-index in the codebase uses one.
  2. Local stacking contexts inside components. Components that need internal layering use isolation: isolate plus local tokens, so their internals don't leak.
  3. Modals and tooltips render through portals or the top-layer primitives. Root-targeted portals move them out of component contexts; top-layer primitives move their rendering above the ordinary stacking-context tree.
  4. A lint rule blocks magic numbers. New code can't introduce uncategorized z-indexes without a deliberate comment.
  5. The Stacking Context Inspector is installed. When something does break, the cause shows up in a few clicks.

Most of that is one-time setup: a fifteen-line token file, a three-line lint rule, and an isolation: isolate per component that needs it. Once it's in place, z-index stops being something to think about — engineers reach for var(--z-modal) by reflex, and the rare bug that slips through has a clear process to find.

If you're starting from scratch, the order that works best is to install the extension first, define the tokens, migrate one component at a time, and turn the lint rule on once the migration is done. By the time the rule is enforcing, there's almost nothing left for it to flag.

PreviousTypeScript didn't make your code safer. You did.NextWhy your useState is in the wrong place

© 2026 Nitesh Seram

Assam, India

GitHub (opens in a new tab)LinkedIn (opens in a new tab)Twitter/X (opens in a new tab)RSSBack to top ↑