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:
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:
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.
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
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
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
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
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
Resolved Stacking Order
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
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.
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
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.