It always starts the same way. You give a dropdown z-index: 9999, it works fine, and then someone hovers a card with a lift effect and the dropdown slides underneath the neighboring card. You try 99999. Nothing changes.

The number was never the problem. The problem is a stacking context you created by accident — and once you understand how contexts work, these bugs stop being mysterious and start being obvious.
The one rule: z-index only competes inside its own stacking context
The mental model that finally made this click for me: a stacking context is a folder. Everything inside it — backgrounds, text, children with z-index — gets painted as a single unit at a single depth. A child with z-index: 9999 inside folder A cannot jump above folder B when folder B paints later. The browser compares folders first, then their contents.
So when your modal with z-index: 100 loses to a plain, unpositioned card that has no z-index at all, the modal's parent is almost certainly a stacking context sitting at a low level. The modal's giant z-index only sorts things inside that parent. From the outside, the entire parent is one flat layer.

What silently creates a stacking context
You already know the intentional trigger: a positioned element (position: relative, absolute, fixed, or sticky) with a z-index other than auto. The ones that actually bite people are the accidental ones:
- opacity below 1 — including mid-animation values. A fade-in at opacity: 0.3 is a stacking context.
- transform, filter, backdrop-filter, perspective, clip-path, mask — any value other than none. Hover lifts, scale effects, blur overlays.
- will-change naming any of those properties, even before the animation runs.
- flex or grid children with a z-index set — no positioning required.
- isolation: isolate and mix-blend-mode values other than normal.
- contain: paint (plus layout, strict, and content, which include it).
- position: fixed — most modern browsers treat it as a context.
Keep this list handy. Half the z-index bugs I've debugged for teammates trace back to one of these lines sitting quietly on an ancestor.
Three bugs you have probably shipped
1. The modal trapped in a transformed card
Setup: a product card gets transform: translateY(-4px) on hover, and a confirm dialog lives inside the card markup with z-index: 100. A sibling card further down the page — no z-index, no positioning — still paints on top of the dialog.
Why: the transform turns the whole card into a stacking context at z-index auto, and the later sibling wins the tie. Your dialog's z-index only sorts elements inside the card, where it was already on top.
Fix: render the dialog outside the transformed ancestor, typically with a portal to the body level. Removing the hover animation just to fix stacking is a bad trade — move the overlay instead.
2. The tooltip that vanishes behind the next card
Classic card-grid setup: every card gets position: relative; z-index: 1 as a defensive reset, and each holds a tooltip with z-index: 10. Card one's tooltip gets clipped by card two, because both cards are equal contexts and card two comes later in the DOM.
Fix: raise the hovered card, not the tooltip. .card:hover { z-index: 10; } promotes the entire context above its siblings, and the tooltip comes along for free. This one line solves a huge share of grid-tooltip complaints.
3. The dropdown killed by a fade-in animation
You add an entrance animation to the header — opacity from 0 to 1 — and suddenly the nav dropdown sits behind the hero section. At any moment the element holds opacity below 1, it becomes a context. If a wrapper keeps a fractional opacity permanently (a common font-rendering hack), the header stays trapped forever.
Fix: remove the lingering fractional opacity, or give the header its own z-index so you control where its context sorts. Since the header is positioned anyway, z-index: 20 works fine.
How the browser actually paints a context
Inside a single stacking context, the paint order is fixed:
- The background and border of the context's root element.
- Children with negative z-index — above the root's own background, below everything else. Handy for decorative layers, not a bug.
- Non-positioned block-level boxes.
- Floats.
- Inline content.
- Positioned and flex/grid children with z-index auto or 0.
- Children with positive z-index, sorted by value.
Two takeaways. First, DOM order is the tiebreaker — when two contexts share a z-index, the later one wins. Second, a huge z-index deep in the tree can never beat a sibling of its own context, no matter how big the number gets.
Debugging stacking with DevTools
My workflow, in order:
- Find the culprit ancestor. In the Elements panel, select the element that should be on top and walk up the ancestor chain. For each parent, scan the Styles pane for the trigger list: transform, filter, opacity, will-change, isolation, contain, position plus z-index.
- Toggle properties off. Untick transform, opacity, and filter on each suspect. The moment the element pops to the front, you have found the context caging it. This takes about thirty seconds once you know what to look for.
- Visualize layers. In Chrome, open More tools → Layers; in Edge, the 3D View panel renders the page's stacking in three dimensions, which makes trapped contexts jump out immediately.
- Confirm with isolation. Temporarily add isolation: isolate to a suspected wrapper. If its children's z-index values stop affecting the rest of the page, you have proven where the boundary is.
One caution: the Layers panel shows compositor layers, which do not map one-to-one to stacking contexts. A context can exist without its own composited layer. Treat it as a hint, not proof.
The rules I follow now
- Put z-index on as few elements as possible. A small scale — 10 for dropdowns, 20 for sticky headers, 30 for modals, 40 for toasts — on top-level wrappers beats random 9999s scattered through components.
- Never animate transform or opacity on a container that holds overlays. Animate leaf elements instead, or portal the overlay out.
- Use isolation: isolate deliberately to fence off a component's internal z-index values — great for design-system components and third-party embeds.
- In card grids, promote on hover: .card:hover { z-index: 2; } with relative positioning on the card.
- While debugging, resist raising the number. Ask instead: which ancestor is a context, and where does that context sit?
Stacking contexts are not exotic. It is a short, learnable list of triggers plus one paint-order rule. Once you internalize that z-index is scoped rather than global, the whole why-is-this-behind-that class of bug mostly disappears — and when it does show up, DevTools plus the trigger list gets you to an answer in under a minute. For the full spec-level detail, the MDN page on stacking contexts is the best reference available.