[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-z-index-not-working-stacking-context":3},{"content":4,"lastModified":5},"\u003Cp>A dropdown menu stopped working in exactly one place on our dashboard. The same component opens fine from the top bar, but the copy that lives in a card toolbar renders behind the sidebar. No console error, no layout shift. The menu just slides under an element that should be nowhere near it.\u003C\u002Fp>\n\n\u003Cp>So I did what everyone does first. \u003Ccode>z-index: 9999\u003C\u002Fcode>. Then 99999. Then a number with seven digits, which is the moment you have to admit the number was never the problem. z-index was working fine. My number simply never had an effect, because nothing was comparing it.\u003C\u002Fp>\n\n\u003Cfigure>\n  \u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1524230572899-a752b3835840?auto=format&fit=crop&w=1200&q=80\" alt=\"A corridor of nested white arches, each one opening into the next\" loading=\"lazy\" \u002F>\n\u003C\u002Ffigure>\n\n\u003Ch2>A stacking context is local, not global\u003C\u002Fh2>\n\n\u003Cp>MDN states it plainly: elements within a stacking context are stacked independently from elements outside of it. A context is also atomic. Once the browser has ordered everything inside one, the whole thing takes part in its parent's order as a single unit.\u003C\u002Fp>\n\n\u003Cp>That is the entire bug. My menu carried a huge z-index, but it sat inside \u003Ccode>.card\u003C\u002Fcode>. The sidebar is \u003Ccode>.card\u003C\u002Fcode>'s sibling. The only comparison the browser made was \u003Ccode>.card\u003C\u002Fcode> against the sidebar, and the menu's number never got a vote.\u003C\u002Fp>\n\n\u003Ch2>The contexts I keep forgetting I created\u003C\u002Fh2>\n\n\u003Cp>Some triggers are common knowledge. An element with \u003Ccode>position: absolute\u003C\u002Fcode> or \u003Ccode>relative\u003C\u002Fcode> and a z-index other than \u003Ccode>auto\u003C\u002Fcode>. An element with \u003Ccode>position: fixed\u003C\u002Fcode> or \u003Ccode>sticky\u003C\u002Fcode>, which creates a context even with no z-index at all. A sticky header makes a stacking context just by existing.\u003C\u002Fp>\n\n\u003Cp>The rest of the list is where it stops being intuitive, because most of it has nothing to do with layering. \u003Ccode>opacity\u003C\u002Fcode> below 1. Any of \u003Ccode>transform\u003C\u002Fcode>, \u003Ccode>scale\u003C\u002Fcode>, \u003Ccode>rotate\u003C\u002Fcode>, \u003Ccode>translate\u003C\u002Fcode>, \u003Ccode>filter\u003C\u002Fcode>, \u003Ccode>backdrop-filter\u003C\u002Fcode>, \u003Ccode>perspective\u003C\u002Fcode>, \u003Ccode>clip-path\u003C\u002Fcode> or \u003Ccode>mask\u003C\u002Fcode> set to something other than \u003Ccode>none\u003C\u002Fcode>. \u003Ccode>mix-blend-mode\u003C\u002Fcode> other than \u003Ccode>normal\u003C\u002Fcode>. \u003Ccode>contain: layout\u003C\u002Fcode> or \u003Ccode>contain: paint\u003C\u002Fcode>. \u003Ccode>isolation: isolate\u003C\u002Fcode>. A \u003Ccode>will-change\u003C\u002Fcode> naming one of those properties, which is how \u003Ccode>will-change: transform\u003C\u002Fcode> added for animation performance ends up trapping every z-index inside a component. And an element faded in through \u003Ccode>@keyframes\u003C\u002Fcode> with \u003Ccode>animation-fill-mode: forwards\u003C\u002Fcode>.\u003C\u002Fp>\n\n\u003Cp>Two more, and both of them were mine. Declaring \u003Ccode>container-type: size\u003C\u002Fcode> or \u003Ccode>container-type: inline-size\u003C\u002Fcode> creates a stacking context permanently, with no z-index anywhere in sight. I gave that card \u003Ccode>container-type: inline-size\u003C\u002Fcode> in July so it could respond to its own width, and it had been fencing off every z-index inside it ever since. \u003Ccode>content-visibility: auto\u003C\u002Fcode> turns on paint containment, which lands you in the same pile, and it usually gets added as a rendering optimization on lists of cards.\u003C\u002Fp>\n\n\u003Cp>The \u003Ca href=\"\u002Fposts\u002Fcss-container-queries-guide\u002F\">container query guide I wrote in July\u003C\u002Fa> is still correct about sizing. It just never mentioned this part.\u003C\u002Fp>\n\n\u003Ch2>Find the ancestor instead of guessing numbers\u003C\u002Fh2>\n\n\u003Cp>Every one of those triggers is readable from \u003Ccode>getComputedStyle\u003C\u002Fcode>, so you can skip the devtools treasure hunt. I keep this in a snippet and run it the moment an overlay misbehaves.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const VISUAL = ['transform', 'scale', 'rotate', 'filter', 'backdrop-filter',\n  'perspective', 'clip-path', 'mix-blend-mode']\n\nfunction contextReason(el) {\n  const s = getComputedStyle(el)\n  if (el === document.documentElement) return 'root element'\n  if (s.position === 'fixed' || s.position === 'sticky') return 'position: ' + s.position\n  const positioned = s.position === 'absolute' || s.position === 'relative'\n  const parentDisplay = getComputedStyle(el.parentElement).display\n  const isItem = parentDisplay.includes('flex') || parentDisplay.includes('grid')\n  if (s.zIndex !== 'auto' && (positioned || isItem)) return 'z-index: ' + s.zIndex\n  if (parseFloat(s.opacity) &lt; 1) return 'opacity: ' + s.opacity\n  if (s.isolation === 'isolate') return 'isolation: isolate'\n  if (s.containerType !== 'normal') return 'container-type: ' + s.containerType\n  if (\u002Flayout|paint|strict|content\u002F.test(s.contain)) return 'contain: ' + s.contain\n  if (s.willChange !== 'auto' && !\u002Fscroll-position|contents\u002F.test(s.willChange)) {\n    return 'will-change: ' + s.willChange\n  }\n  for (const prop of VISUAL) {\n    const v = s.getPropertyValue(prop)\n    if (v !== 'none' && v !== 'normal') return prop + ': ' + v\n  }\n  return null\n}\n\nfunction fences(el) {\n  const hits = []\n  for (let n = el; n; n = n.parentElement) {\n    const reason = contextReason(n)\n    if (reason) hits.push([n, reason])\n  }\n  return hits\n}\n\nfences(document.querySelector('.toolbar-menu'))\n  .forEach(([el, reason]) => console.log(el, reason))\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Start at the last line of that output and walk up. The first entry with a z-index of its own is the decisive one, because that is the number the browser actually compared. My card was a plain static block, so \u003Ccode>z-index: 3\u003C\u002Fcode> on it did nothing until I gave it \u003Ccode>position: relative\u003C\u002Fcode>. On a static element, z-index is a property the browser ignores.\u003C\u002Fp>\n\n\u003Ch2>The bug that looks the same and is not\u003C\u002Fh2>\n\n\u003Cp>A few weeks earlier, a different overlay had the same search phrase and a different cause. Nothing was covering it. A panel with \u003Ccode>position: fixed\u003C\u002Fcode> had simply moved: it scrolled away with the page and got clipped by the card it had started inside.\u003C\u002Fp>\n\n\u003Cp>The mechanism there is the containing block. If an ancestor between a fixed element and the root has a \u003Ccode>transform\u003C\u002Fcode>, \u003Ccode>filter\u003C\u002Fcode>, \u003Ccode>backdrop-filter\u003C\u002Fcode>, \u003Ccode>perspective\u003C\u002Fcode>, \u003Ccode>rotate\u003C\u002Fcode>, \u003Ccode>scale\u003C\u002Fcode> or \u003Ccode>translate\u003C\u002Fcode> value other than \u003Ccode>none\u003C\u002Fcode>, or a \u003Ccode>contain\u003C\u002Fcode> of layout, paint, strict or content, or \u003Ccode>content-visibility: auto\u003C\u002Fcode>, or a \u003Ccode>will-change\u003C\u002Fcode> naming any of those, that ancestor becomes the containing block. Fixed stops meaning the viewport and starts meaning that ancestor, and the ancestor's \u003Ccode>overflow\u003C\u002Fcode> clips it from then on. MDN flags browser inconsistencies around \u003Ccode>perspective\u003C\u002Fcode> and \u003Ccode>filter\u003C\u002Fcode> here, which is reason enough not to lean on them.\u003C\u002Fp>\n\n\u003Cp>No z-index fixes that one. The element is not losing a paint comparison, it is being laid out against the wrong box.\u003C\u002Fp>\n\n\u003Ch2>Fix it at the level that pays\u003C\u002Fh2>\n\n\u003Cp>Moving the overlay out of the trap is the only fix that keeps working as an app grows. If it is a modal or a popover, \u003Ccode>showModal()\u003C\u002Fcode> or \u003Ccode>showPopover()\u003C\u002Fcode> puts it in the \u003Ca href=\"\u002Fposts\u002Fhtml-dialog-element-native-modals\u002F\">top layer\u003C\u002Fa>, which the browser paints above every stacking context on the page. The catch is that you cannot opt in: only fullscreen elements, modal dialogs, popovers and an open select picker ever land there, and no amount of CSS promotes a plain div into it. That is why wrapping a tooltip in a popover turned into structural work rather than a styling tweak.\u003C\u002Fp>\n\n\u003Cp>Raising the ancestor works too, and for one card it is the cheapest fix. Do it five times and you have the pile of z-index values nobody can reason about later. When I do want a local scale, I build it on purpose. \u003Ccode>isolation: isolate\u003C\u002Fcode> hands a component its own context, so the numbers inside stay small.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-css\">:root {\n  --z-dropdown: 10;\n  --z-sticky: 20;\n  --z-drawer: 30;\n  --z-modal: 40;\n  --z-toast: 50;\n}\n\n.toast-stack {\n  position: fixed;\n  inset-block-start: 1rem;\n  inset-inline-end: 1rem;\n  z-index: var(--z-toast);\n}\n\n.card {\n  \u002F* a local order for this component, on purpose *\u002F\n  isolation: isolate;\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>None of this has anything to do with \u003Ca href=\"\u002Fposts\u002Fcss-cascade-layers\u002F\">cascade layers\u003C\u002Fa>. Those decide which declaration wins a conflict, stacking contexts decide what paints on top. Two unrelated systems, and I lost most of an afternoon to that confusion once.\u003C\u002Fp>\n\n\u003Ch2>What no number will fix\u003C\u002Fh2>\n\n\u003Cp>Two places no z-index reaches: shadow DOM and iframes. The host element of a shadow tree is the only part that takes part in the parent's stacking order, so nothing inside a web component climbs out of it. An iframe's contents cannot paint above the page hosting them either.\u003C\u002Fp>\n\n\u003Cp>The walker above is the one thing I kept from that afternoon. It sits in \u003Ca href=\"\u002Fsnippetark\u002F\">my snippet collection\u003C\u002Fa> next to the z-index scale, and it runs before I touch a number now.\u003C\u002Fp>\n","2026-10-01",1791168103941]