Beyond Z-Index

How browser stacking context really works - from z-index traps to the top layer.

01The Problem

You've been there.

You set z-index: 9999 on your modal. It still renders behind the sidebar. So you try 99999. Then 999999. You add position: relative to the parent. You move the element to the end of the DOM. You start questioning your career choices.

The problem isn't the number. It's that z-index doesn't work the way most people think it does.

z-index only compares elements within the same stacking context. If your modal lives inside a container that has transform, opacity < 1, filter, or dozens of other CSS properties - it's trapped. No z-index value, no matter how large, will break it out.

This is the stacking context trap, and it's the root cause of almost every z-index frustration you've ever had.

Try it yourself:

z: 10Sidebar
Content
z: 9999
Modal
z-index: 9999
Stacking context on parent
⚠️ Modal is trapped
.content {
transform: translateX(0);
}

See what happened? The modal has z-index: 9999 but it can't escape its parent's stacking context. The sidebar with just z-index: 10 wins because it's in a higher stacking context.

This is the fundamental problem with z-index. And for years, the only solutions were hacks: portal the element to <body>, restructure your DOM, or keep incrementing that number and praying.

Modern browsers now have a real answer: the top layer.

But even the top layer has a gotcha that bit me in production.

I used dialog.showModal() for a confirmation modal (more about modals and top layer in the next sections) - it escapes all stacking contexts and gets a free ::backdrop overlay. Problem solved, right? Then I triggered a toast notification. The toast was in normal document flow. The ::backdrop pseudo-element covers the entire viewport from inside the top layer. My toast was invisible - buried behind it.

Here's exactly what happens:

sharqiewicz
Open a modal to start

The fix: popover="manual". It promotes an element to the top layer. Wrap the toast container in a popover, call showPopover() when toasts are active, and they render above everything - including ::backdrop.

To understand why ::backdrop beats the toast, and why popover fixes it, we need to go back to fundamentals. Let's understand how stacking works from the ground up.

02How Stacking Works

Before we talk about z-index, there's a simpler rule: without any positioning or z-index, later elements in the DOM paint on top of earlier ones. The browser walks the HTML top to bottom, painting each element over the previous ones.

1st in DOM1
2nd in DOM2
3rd in DOM3
Spread apart
/* No z-index - later DOM elements paint on top */
<div> 1st</div>
<div> 2nd - paints above 1st</div>
<div> 3rd - paints above 2nd</div>

Source order is the baseline. Everything else - the 7-layer paint order, z-index, stacking contexts - builds on top of it.

The browser paints every stacking context in a fixed 7-layer order, back to front. Most developers only think about the last layer - positive z-index - but six others paint before it.

This is a simplification - the CSS2 spec (Appendix E) defines ~10 granular sub-steps, but these 7 layers capture the key painting decisions.

CSS Paint Order

1Background & borders
2Negative z-index
3Block-level elements
4Floats
5Inline content
6z-index: 0 / auto
7Positive z-index
Explode layers

Notice layers 3, 4, and 5: a float sits above a block element's background and border, but below inline content like text. This is why text wraps around floats rather than hiding behind them - the browser paints the float between the block background and the inline layer.

Float Stacking

float
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.
Float element
Peek through float

There's one more fundamental rule: z-index only works on positioned elements (or flex/grid children). On a position: static element - which is the default - z-index does absolutely nothing.

z-index Requires Positioning

Box Az: 1position: relative
Box Bz: 9999position: static
Set position: relative on Box B
⚠️ z-index ignored on static element
.box-b {
position: static;
z-index: 9999;
}

This paint order is deterministic. Once you know the 7 layers, source order, the positioning requirement, and negative z-index behavior - stacking stops being surprising.

Now let's talk about what creates the boundaries these layers live inside.

03Stacking Contexts

Dozens of CSS properties create stacking contexts - not just z-index with position. Adding a "harmless" transform: translateZ(0) for GPU acceleration can silently trap your dropdown behind a sibling.

Click properties to see stacking contexts form:

Stacking Context Creators

Container
z: 9999
Popup
Siblingz: 1
✓ No stacking context
.container {
/* no stacking context */
}

Every one of those properties created the same trap. The popup with z-index: 9999 can't escape the container once any of them are applied.

And it's not just these. position: fixed and position: sticky always create stacking contexts - even without a z-index. Flex and grid items with any z-index value other than auto do too, without needing position.

The demo above shows 6 common creators, but the full list on MDN has 20+ properties including container-type, mix-blend-mode, clip-path, mask, and contain: paint.

A helpful way to think about nested stacking contexts is the "version number" model. A parent's z-index is the major version; a child's z-index is the minor version. A child at 3.9999 always loses to a sibling context at 5.0, no matter how high the minor version gets.

Version Number Mental Model

Context Tree

root (v0)
Sidebarz: 5v5
Navz: 2v5.2
Mainz: 3v3
Cardz: 99v3.99
Modalz: 9999v3.9999
Headerz: 10v10

Resolved Stacking Order

1Headerv10
2Navv5.2
3Sidebarv5
4Modalv3.9999
5Cardv3.99
6Mainv3

Stacking contexts are invisible side effects - you have to know to look for them. But what if you could escape them entirely?

04The Top Layer

The browser has a secret weapon: the top layer. It sits above every stacking context, every z-index value, every transform. It's not part of the document's stacking order at all.

dialog.showModal() promotes an element to the top layer. It also gives you a ::backdrop pseudo-element for free.

Compare the two approaches:

z-index vs Top Layer

Headerz: 100
Sidebarz: 50
Content (transform: scale(1))
Modal
z-index: 10
Choose an approach

With z-index, the modal fights within the document. With showModal(), it leaves entirely. The <dialog> element was the first API for this. But it's not the only one.

05The Popover API

The Popover API is the modern way to create lightweight overlays. Add a popover attribute to any element, point a button at it with popovertarget, and the browser handles top-layer promotion, light dismiss, and focus restoration - zero JavaScript.

Old Way (z-index)
transform: scale(1)
Sibling (z: 1) overlaps dropdown
New Way (popover)
transform: scale(1)
Edit
Duplicate
Delete
Sibling (z: 1) - no overlap!
<!-- Popover API -->
<button popovertarget="menu">Open Menu</button>
<div popover id="menu">...</div>

The left side is years of z-index battles and manual click-outside handlers. The right side is one HTML attribute.

The popover enters the top layer when shown and leaves when dismissed. No stacking context can trap it.

There's also a third state: popover="hint" - designed for tooltip-like ephemeral content that dismisses when another hint opens.

06The Top Layer Stack

Here's the key insight that ties everything together: the top layer is an ordered set. Every time you promote an element - via showModal(), showPopover(), or fullscreen - it goes to the top of the stack. The last element promoted sits above everything else.

This is exactly why the popover="manual" fix from section 1 works. The dialog enters the top layer first (along with its ::backdrop). When the toast container calls showPopover(), it enters after the dialog - so it renders above both the dialog and its backdrop.

Watch the stack build up:

Top Layer Stack Order

Top Layer
1<dialog>showModal()
::backdropauto with modal
Document
Document<body>
Open a modal to start

The rule is simple: last promoted = on top. If you need something above a modal, promote it to the top layer after the modal.

07When to Use What

The rule of thumb: modal <dialog>. Lightweight overlay popover. Local layering → z-index.

The escape hatch from stacking context problems is always the top layer.

Newsletter

Stay updated with my latest articles and projects. No spam, no nonsense.