如果一个设置了 z-index: 9999 的元素仍然渲染在 z-index 为 2 的元素下方,说明前者的某个祖先元素创建了层叠上下文(stacking context),而 z-index 永远无法作用到其所属上下文之外。
你已经试过 99999,接着又试了 2147483647。模态框依然压在页头下面,而 Styles 面板里看起来一切正常。
关于层叠顺序背后的原理,请参阅 CSS: Demystifying the z-index property。本指南侧重于调试:如何在 DevTools 中查看层叠上下文,如何沿 DOM 逐级向上排查,直到找到困住目标元素的祖先,以及如何在不调大数值的前提下修复各种常见情况。
核心要点
- z-index 值只会与同一层叠上下文中的元素比较,因此当第一个元素的某个祖先拥有自己的层叠上下文时,
9999也会输给2。 - 当 z-index 更高的元素反而落败时,应比较两个元素各自位于同一父元素下的祖先,而不是元素自身的值。
- 任何非
none的transform、filter或clip-path,以及小于 1 的opacity,都会创建新的层叠上下文,即使该元素既没有设置position也没有设置z-index。 - 下拉菜单在卡片边缘被截断,通常是被
overflow裁剪了,任何 z-index 值都无法解决这个问题。 - 将浮层移动为
<body>的直接子元素,可以让它脱离所有祖先的层叠上下文。
z-index 失效背后的唯一规则是什么?
z-index 只决定一个元素与其所在层叠上下文中其他成员之间的顺序。层叠上下文是浏览器作为一个整体进行绘制的一组元素。MDN 的层叠上下文指南列出了会创建层叠上下文的属性。当某个祖先创建了上下文后,子元素的 z-index 只在该祖先内部生效。因此,如果这个祖先位于另一个上下文之下,那么无论你给子元素设置多大的数值,它也只能留在下面。
下面是这个 bug 的最小复现:
<header style="position: fixed; top: 0; z-index: 2; background: tomato;">Header</header>
<main style="opacity: 0.99;">
<div class="modal" style="position: fixed; inset: 0; z-index: 9999; background: rgb(0 0 0 / 0.6);">
Modal
</div>
</main>
模态框之所以落败,是因为 main 设置了 opacity: 0.99,这使 main 成为一个层叠上下文。在 main 内部,模态框的 9999 完全生效。但 main 本身是在根层级与页头进行比较的,而页头的 2 更高。当 z-index 更高的元素反而落败时,就去看它们的祖先:分别找到两个元素与对方共享同一父元素的最近祖先,然后比较这两个祖先。
如何在 DevTools 中查看层叠上下文?
Chromium DevTools 提供了用于排查层级问题的可视化工具,往往比靠猜测快得多。在 Chrome 和 Edge 中,这些视图都可以通过 Command Menu(命令菜单)打开:macOS 上按 Command+Shift+P,Windows 和 Linux 上按 Control+Shift+P。更多常被忽视的 DevTools 面板,请参阅 Hidden Gems in Chrome DevTools。
Chrome:Layers 面板
- 打开 DevTools,按 Command+Shift+P 或 Control+Shift+P。
- 输入
Layers,选择 Show Layers panel。也可以通过 More options > More tools > Layers panel 打开。 - 旋转画布并选中某个图层,即可查看其详细信息,包括浏览器为什么要为它单独创建图层。
Layers 面板左侧显示页面图层的树状结构,旁边则是这些图层的 3D 示意图。你可以用它来检查浏览器如何将页面拆分为合成层(compositing layers),但请注意,合成层与层叠上下文并不是一回事。要找出导致层叠上下文的祖先,请使用下文介绍的逐级上溯法。该面板在调试卡顿的 CSS 动画时也会用到。
Edge:3D View
- 打开命令菜单并运行 Show 3D View。
- 选择 Z-index 选项卡。
- 勾选 Show all 选项,即可同时查看定位元素和层叠上下文。
Edge 的 3D View 包含三个选项卡:Composited Layers、Z-index 和 DOM。Z-index 选项卡会以 3D 形式绘制页面的层叠上下文,因此被困住的元素会显示为嵌套在某个较低层板中。
如果你想在 Chrome 或 Firefox 中以树状视图查看层叠上下文,可以使用 CSS Stacking Context inspector 扩展。它会添加一个显示上下文树的面板,并在 Elements 侧边栏中说明所选元素为何会创建上下文。根据其文档,它目前还无法检查第三方 iframe 内部的内容。
祖先逐级上溯法
逐级上溯法只需借助 Elements 面板和 Computed 窗格,就能找到困住目标元素的祖先。它在所有浏览器中的用法都相同。
- 选中落败的元素(模态框、菜单等)。
- 在 Elements 面板中点击它的父元素。
- 打开 Computed 窗格,在筛选框中逐一输入下方清单中的属性:
transform、opacity、filter、position、z-index等。 - 如果这些属性都没有创建上下文,就向上移动一级并重复,直到抵达
<html>。 - 记下第一个创建了上下文的祖先。
- 从胜出的那个元素开始,重复步骤 1 到 5。
- 两条祖先链会在某个共同祖先处交汇。该共同祖先位于这两条链上的两个子元素,才是真正被拿来比较的元素。
如果你是在处理 bug 报告,而不是本地复现,会话回放(session replay)可以从用户视角呈现问题症状,例如点击落在了一个不可见的浮层上,或者菜单在内容后面展开。这能告诉你在步骤 1 中应该选中哪个元素。
清单:会创建层叠上下文的属性
用这张表逐一检查每个祖先。只要命中任意一行,就足以困住其后代元素。
| 属性 | 何时创建上下文 | 在 Computed 中关注什么 |
|---|---|---|
position: absolute / relative | z-index 不为 auto | 同一元素上同时设置了这两个值 |
position: fixed / sticky | 始终创建 | 仅看 position 值即可 |
opacity | 小于 1 | 类似 0.99 的值,常是淡入淡出动画遗留下来的 |
transform、scale、rotate、translate | 任何非 none 的值 | matrix(...) 值,包括单位变换(identity transform) |
filter、backdrop-filter | 非 none | blur()、drop-shadow() |
clip-path、perspective | 非 none | 任意形状或长度值 |
mask、mask-image、mask-border | 非 none | 图像或渐变值 |
will-change | 列出了某个一旦设置就会创建上下文的属性 | will-change: transform 或 opacity |
isolation | isolate | 显式设置的值 |
contain | layout、paint、strict 或 content | 在虚拟列表中很常见 |
| Flex 或 Grid 子元素 | z-index 不为 auto | 父元素为 display: flex 或 grid,无需设置 position |
MDN 的完整列表还包括非 normal 的 mix-blend-mode、顶层(top layer)元素,以及其创建上下文的属性通过 animation-fill-mode: forwards 进行动画的元素。MDN 目前仍将 container-type: size 和 inline-size 列入其中,但 CSS 工作组已将布局包含(layout containment)从 container-type 中移除,因此它已不再创建层叠上下文。Chrome 在 129 版本中做出了这一变更,Firefox 和 Safari 随后也已跟进。
三种常见情况:症状、原因与修复
模态框位于页头下方
**症状:**遮罩层让页面变暗了,但页头依然明亮地浮在其上方。**原因:**模态框渲染在一个带有 transform、opacity 或已定位且设置了 z-index 的包裹元素内,而页头所在的上下文层级高于该包裹元素。**修复:**将模态框移动到 <body> 下(参见下文的修复方法)。
下拉菜单在带有 transform 的卡片中被截断
位于带 transform 卡片内的下拉菜单,可能遇到层叠或裁剪两种问题之一,所以首先要判断是哪一种。如果菜单被绘制在下一张卡片的后面,那就是层叠问题:带 transform 的卡片是一个上下文,DOM 中位于其后的卡片会绘制在它上方。此时应提升已展开卡片的层级:
.card.is-open { position: relative; z-index: 1; }
如果菜单在卡片自身边缘被截断,那就是裁剪问题。请检查卡片及其祖先上的 overflow。任何非 visible 的值都会裁剪菜单,无论设置多大的 z-index 都无法让它显示出来。
粘性元素无法保持在最上层
**症状:**悬停或滚动时,卡片从粘性页头上方滑过。**原因:**任何设置了 position: fixed 或 sticky 的元素都会创建上下文。但在没有 z-index 的情况下,它处于第 0 层,而后续创建了上下文的兄弟元素(例如带有悬停 transform 效果的卡片)由于在 DOM 中位置更靠后,会绘制在它上方。修复:
.site-header { position: sticky; top: 0; z-index: var(--z-sticky); }
如果页头根本没有粘住,原因通常是某个祖先的 overflow。完整的粘性页头实现,请参阅 Building a responsive sticky navbar with CSS。
修复被困元素的三种方法
所有修复被困元素的方法,改变的都是层叠上下文的结构,而不是 z-index 的数值:移动元素、移动属性,或者有意地在你需要的位置设置边界。
**在 DOM 中移动元素。**作为 <body> 直接子元素的浮层,上方不存在任何祖先上下文,因此它的 z-index 会在根层级进行比较。这正是 portal 非常适合需要覆盖整个页面的浮层的原因:
const modal = document.querySelector('.modal');
function openModal() {
document.body.appendChild(modal); // escape every ancestor context
modal.hidden = false;
}
**移除或转移属性。**Lullabot 的 Andy Blum 描述过这样一个案例:在一个从右到左(RTL)布局中,背景视频容器通过 transform 进行了镜像翻转,结果导致视频的播放/暂停按钮被埋在下面。他的解决办法是把 transform 从容器移到视频 iframe 上:
/* Before: the wrapper becomes a context */
[dir="rtl"] .hero { transform: scaleX(-1); }
/* After: only the media is mirrored */
[dir="rtl"] .hero iframe { transform: scaleX(-1); }
**有意地添加隔离。**在组件根元素上设置 isolation: isolate,可以主动创建一个上下文,使组件内部的所有 z-index 都只在局部生效:
.card { isolation: isolate; }
.card .badge { position: absolute; z-index: 1; } /* never competes with the site header */
用 z-index 层级体系预防问题
出现 9999 这样的 z-index,几乎总是意味着有人没有找到层叠上下文,而不是真的需要更大的数值。请用一套精简且有文档说明的层级体系来取代零散的数值:
:root {
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
}
.modal { z-index: var(--z-modal); }
这些 z-index 层级 token 只能对处于同一层叠上下文中的元素进行排序。因此,它们最好与用于页面级图层的 portal,以及应用于组件根元素的 isolation: isolate 配合使用。
总结
z-index 不起作用,几乎总是层叠上下文的问题,而不是数值的问题。下次再遇到 9999 落败时,不要去动这个值。先打开 Layers 面板或 3D View,确认各元素的实际绘制位置,然后对照清单从两个元素分别向上排查,直到两条祖先链交汇。共同祖先的那两个子元素才是真正被比较的对象,修复也应该落在其中之一上。
常见问题
为什么 z-index 对我的元素完全没有效果?
z-index 仅适用于定位元素(即 position 设置为 relative、absolute、fixed 或 sticky 的元素)以及 flex 或 grid 子项。对于使用默认 position: static 且不是 flex 或 grid 子项的元素,浏览器会完全忽略 z-index。添加 position: relative 让该值生效,然后再检查是否仍有祖先层叠上下文在限制它。
通过 showModal() 打开的 dialog 能否摆脱层叠上下文?
可以。对 dialog 元素调用 showModal() 会将其提升到顶层(top layer),顶层位于文档中所有层叠上下文之上。祖先的 transform 和 z-index 值都不再影响它的顺序。通过 showPopover() 显示的 popover 也会以同样的方式被提升。z-index 在顶层内部不起作用:元素按照被提升的顺序层叠,因此最近打开的元素显示在最上方。
为什么我的 position: fixed 浮层是相对于父元素而不是视口定位的?
带有非 none 的 transform、perspective 或 filter 的祖先,会成为其 fixed 定位后代的包含块(containing block)。此时位于其内部的 fixed 浮层会相对于该祖先而不是视口进行定位和计算尺寸。同一属性还会创建层叠上下文,因此浮层可能同时出现被其他内容遮挡和覆盖区域错误的问题。将浮层移动到 body 下可以同时解决这两个问题。
负的 z-index 能让子元素位于父元素后面吗?
只有在父元素不创建层叠上下文时才可以。在层叠上下文内部,浏览器会先绘制上下文根元素自身的背景和边框,然后绘制 z-index 为负的子元素,最后绘制常规流内容。如果父元素的 opacity 小于 1、带有 transform,或者已定位且 z-index 不为 auto,那么 z-index: -1 的子元素仍会绘制在父元素背景之上,只是位于其常规内容之下。
Gain control over your UX
See how users are using your site as if you were sitting next to them, learn and iterate faster with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k