skip to content

In CSS, why can an element with z-index: 9999 still paint behind an element with z-index: 1?

level: middleimportance: must knowfreq 78%

answer

  1. numbers are local, not global
  2. look up the ancestor chain
  3. the subtree paints as one unit
  4. a parent's opacity can trap a child
  5. fix the ancestor, not the number

basics

~20 s

z-index only orders elements within a single stacking context. If an ancestor of the 9999 element forms a stacking context that paints below the other element, the whole subtree paints with that ancestor, and no z-index value can escape it.

solid answer

~40 s

z-index is not a global depth scale — it only compares participants inside the **same stacking context**. Once an ancestor creates a stacking context, everything inside it is painted as one atomic unit at the ancestor's own level, and the descendants' z-index values are only compared against each other. So if my element has `z-index: 9999` but sits inside a parent that has, say, `opacity: 0.9` or a `transform`, that parent is the thing being ordered against the `z-index: 1` element — and if the parent loses, the 9999 child loses with it. The fix is never a bigger number. It is to raise or remove the stacking context on the *ancestor*, or to move the element out of that subtree entirely so the two elements become siblings in one shared context.

code

html · 9 lines
html
<style>
  .card   { position: relative; z-index: 1; opacity: 0.99; }
  .tooltip{ position: absolute; z-index: 9999; top: 10px; left: 10px;
            background: gold; padding: 4px; }
  .panel  { position: relative; z-index: 1; margin-top: -30px;
            background: teal; height: 60px; }
</style>
<div class="card">card<div class="tooltip">tooltip</div></div>
<div class="panel"></div>

go deeper

for a junior

Know that z-index compares elements inside the same group, not across the whole page, and that adding more nines is not a fix. Be able to say the number is local.

for a middle

Explain the atomicity rule: a stacking context paints as one unit, so outside elements land wholly above or below it. Walk the ancestor chain out loud when diagnosing.

for a senior

Show the debugging procedure — find the trapping ancestor, decide whether to raise it, delete an accidental context, or relocate the overlay — and name why each option has different blast radius.

for a principal

Own the structural answer: define where overlays render and which layers may declare z-index at all, so component authors never need to reason about a global scale in the first place.

## The mental model that causes the bug Most people read `z-index` as "how far forward on the screen this element sits", as if the page had one global depth axis from 0 to infinity. It does not. `z-index` is a **local** ordering number, and the scope it is local to is called a *stacking context*. ## What a stacking context is A stacking context is a self-contained group of elements that the browser paints as one atomic unit. It has a root element (the element that created it) and it contains every descendant, except descendants that create their own nested stacking contexts — those become atomic units nested inside this one. The key property is atomicity: **nothing from outside a stacking context can be painted in between two things inside it.** Either the whole group paints before the outside element, or the whole group paints after it. Once you accept that, the 9999-loses-to-1 behaviour is not a quirk; it is the direct consequence. The root element (`<html>`) always establishes the root stacking context, so there is always at least one. ## Why 9999 loses Consider this structure: ```css .panel { position: relative; z-index: 1; } .card { position: relative; z-index: 1; opacity: 0.99; } .tooltip{ position: absolute; z-index: 9999; } ``` ```html <div class="card"><div class="tooltip">tip</div></div> <div class="panel">panel</div> ``` `.card` has `opacity: 0.99`, which creates a stacking context, so `.tooltip` is trapped inside it. The browser now has exactly two things to order at the top level: `.card` (z-index 1) and `.panel` (z-index 1). They tie, so document order breaks the tie and the later element — `.panel` — paints on top. `.tooltip`'s 9999 is never compared with `.panel` at all; it only says "paint me last *among .card's descendants*". The whole `.card` group, tooltip included, goes under `.panel`. ## How z-index values are compared Within one stacking context, the participants are sorted by their integer z-index, and elements with equal values (or `auto`/`0`) are painted in document order — later markup on top. Values may be negative: a child with a negative z-index paints *behind* its stacking-context parent's in-flow content but still in front of that parent's own background and borders. There is no arithmetic between levels. A child's 9999 is not added to the parent's 1, and it is not clamped to it. It simply lives in a different comparison set. ## Diagnosing it The procedure is mechanical. Start at the element that is losing and walk **up** the ancestor chain toward `<html>`, asking at each step: does this ancestor create a stacking context? The moment you find one, stop — that ancestor is the element actually competing with your rival, and its position in the order is the only thing that matters. Compare *it* against the element you wanted to be under. Browser devtools help here: the Elements panel in Chrome and Firefox badges nodes that establish stacking contexts, and Firefox's layout tools can list them, so you rarely have to reason from the CSS alone. ## The fixes that actually work There are only three real moves, and "add another nine" is not one of them: 1. **Raise the ancestor.** If the trapping ancestor is itself positioned, give *it* the higher z-index. Ordering happens at that level, so that is where the number belongs. 2. **Remove the stacking context.** Often the ancestor got one by accident — a leftover `will-change`, a `transform: translateZ(0)` added for a long-gone paint hack, an `opacity: 0.99`. Delete it and the descendant rejoins the outer context. 3. **Move the element out.** Render the overlay as a child of a high-level container so it and its rival are siblings in the same context. This is why overlay elements are commonly appended near the end of `<body>` rather than left inside the component that triggered them. ## The takeaway to say out loud "z-index compares siblings within one stacking context, not elements across the page. A large number only wins locally; if an ancestor traps it, the ancestor is what is being ordered." That sentence is the whole answer, and everything above is the machinery behind it.

  • If removing the ancestor's opacity is not an option because it is animating, how do you still get the tooltip on top?
    Move the tooltip out of that subtree so it becomes a sibling of the competing element — render it in a container near the end of `<body>` and position it against the trigger. Alternatively, raise the animating ancestor's own z-index if it is positioned, since that ancestor is the unit actually being ordered.
  • Does a child's z-index ever get compared with an element outside its stacking context?
    No. A stacking context is atomic: the browser paints the whole group at the ancestor's level, so outside elements can only land entirely above or entirely below it. Descendant z-index values are compared only against each other, which is exactly why a huge number can still lose.
  • Two positioned siblings both have z-index: 1. What decides which one wins?
    Document order. Within one stacking context, equal stack levels are painted in the order they appear in the source, so the later element paints on top. That tie-break is also why an element with no z-index at all can still cover an earlier positioned sibling.

saying these in an interview costs you the question

  • Thinks z-index is a global, page-wide depth scale
  • Fixes overlap bugs by raising the number until it works
  • Believes a child's z-index adds to or inherits the parent's
  • Never inspects ancestors when an overlay is hidden
  • Assumes only position and z-index can trap a subtree

context