笔记

z-index 99999 输给了 z-index 1,元凶是 position: sticky

A modal in our back office started rendering behind the page. Not invisible — it was there, painted, obviously alive. It was just confined to a strip about sixty pixels tall at the top of the window, with the rest of the page drawn straight over it.

Its CSS said position: fixed; inset: 0; z-index: 99999. The content it was losing to had z-index: 1.

The symptom points at the wrong thing

Because the overlay looked washed out where it did show, I spent the first stretch of this convinced it was a transparency problem. I went through opacity, then backdrop-filter, then background alpha, then whether some parent had a blend mode. All of it was wasted — there was nothing wrong with any of them. What I was seeing wasn't a translucent modal. It was page content drawn on top of an opaque one.

The actual cause was one declaration on an ancestor, five levels up:

<div class="toolbar">
  Filters
  <div class="overlay">MODAL — z-index: 99999</div>
</div>
<div class="page">page content, z-index: 1</div>

.toolbar { position: sticky; top: 0; }        /* ← the entire bug */
.overlay { position: fixed; inset: 0; z-index: 99999; }
.page    { position: relative; z-index: 1; }
这就是整个 bug 的最小复现,不需要任何别的东西。

Here is that markup rendered in a real browser, next to the identical markup with position: sticky deleted:

Side by side: with a sticky ancestor the fixed overlay only paints inside the toolbar; without it the overlay covers the viewport.
同一份代码。左边有 sticky 祖先,z-index 99999 的全屏弹窗只画在工具栏那条里;右边删掉 sticky 就正常铺满。

Why sticky does this

A position: sticky element always creates a stacking context. Not conditionally, not only when you give it a z-index — always. That is different from position: relative, which only creates one once z-index is something other than auto, and it is why this catches people.

Once an ancestor owns a stacking context, every z-index inside it is resolved within that context. Your 99999 doesn't compete with the rest of the page any more — it only competes with the toolbar's other children. The whole toolbar then takes its own place in the page's stacking order, and your modal is a passenger inside it. There is no number large enough to escape, because the number is being read in the wrong room.

A useful way to hold it: z-index is not a global ranking. It is a rank among siblings inside whichever stacking context you happen to be in — and lots of ordinary CSS silently creates one.

Diagnosing it in one paste

You don't have to guess which ancestor is responsible. Walk up the tree and print every element that creates a stacking context:

// Paste this in the console while the broken modal is on screen.
// Walk up from the overlay and print anything that creates a stacking context.
let el = document.querySelector('.overlay')
while ((el = el.parentElement)) {
  const s = getComputedStyle(el)
  if (['sticky','fixed'].includes(s.position) ||
      s.transform !== 'none' || s.filter !== 'none' ||
      s.willChange !== 'auto' || s.opacity !== '1' ||
      s.contain !== 'none' || s.isolation === 'isolate') {
    console.log(el, s.position, s.transform, s.filter, s.opacity)
  }
}
打印出来的第一个,基本就是元凶。

It is worth knowing the rest of that list, because sticky is only the one that caught me. transform, filter, opacity below 1, will-change, contain, isolation: isolate, and backdrop-filter all create a stacking context too. A CSS animation library that adds transform: translateZ(0) for GPU compositing will do this to you and never mention it.

The fix, and the rule that follows from it

Raising the z-index cannot work. The modal has to be rendered somewhere that isn't inside the trapping context at all — which in React means a portal:

import { createPortal } from 'react-dom'

export function Overlay({ children }) {
  // Render into <body>, outside every stacking context on the page.
  return createPortal(<div className="overlay">{children}</div>, document.body)
}
Portal 改的是「在 DOM 里渲染到哪」,不是「在组件树里属于谁」。状态和事件照常。

The rule I now apply without thinking about it: anything that must cover the viewport gets portaled to document.body — modals, dropdowns, toasts, tooltips, command palettes. Not because I know there's a stacking context in the way, but because I can't guarantee there isn't one, and the failure is silent, visual, and points at the wrong suspect.