z-index: 99999 输给 z-index: 2?原生弹窗的 Top Layer 排位战:后进者居上、看得见却点不着

上一篇讲到了top-layer和z-index的内容

上一篇说到了showPopover() 之后的气泡,能压住 z-index: 99999 的元素。

但是气泡而已,给 toast 也上 popover 不就完了,这算什么难点。

先把上一篇遗留的问题说完,这篇主要讲的是z-index: 99999 输给 z-index: 2**、dialog 自己也有两种、以及会发现一个有有意思的现象,一个气泡,看得见,点不着**。

一、Top Layer 是什么

浏览器渲染页面时,除了你熟悉的那棵 DOM 渲染树,还有一层独立存在的顶层(Top Layer)。模态 dialog、打开状态的 popover,都会被浏览器直接挂到这一层。

它的规矩和 DOM 里完全不同:

js 复制代码
// 进场方式只有两张门票
dialog.showModal();   // 模态 dialog
popoverEl.showPopover();  // popover

// 验证状态::modal 伪类(Chromium 143 实测命中)
dialogEl.matches(':modal');  // true

在浏览器中的展现是这个样子:

如果有多个的话,会依次向下排列

CSS 中也没有属性能把你送进这一层

HTMHell 2025 年 12 月那篇 Top Layer 专文的原话是「目前没有任何属性可以提升元素到 top layer」。

z-index: 99999 在这里失效,不是因为数字不够大,而是因为 z-index 这套规则在 Top Layer 根本不参与。z-index 的游戏是「同一层叠上下文内比大小」,而 Top Layer 压根不在页面那棵上下文树里。

z-index 是班级排名,Top Layer 是另一个年级。

二、你的 z-index 大战为什么总输

先看一场我在本地跑的对抗赛:

html 复制代码
<div class="trap-wrap transformed" id="wrap"> 
  <div class="trap-inner">z-index: 99999</div>
</div>
<div class="rival">z-index: 2</div>
css 复制代码
.trap-wrap.transformed { transform: translateZ(0); }
.trap-inner { position: absolute; inset: 0; z-index: 99999; }
.rival { position: absolute; z-index: 2; }

自己先猜一下谁赢,再往下看。

perl 复制代码
T3a 父级有 transform: 重叠点(130,555) 最顶层=rival ✅ z-index:2 打败了 99999(被封顶)
T3b 父级去掉 transform: 同一点最顶层=trap-inner ✅ 99999 反杀成功(z-index 没变,输赢变了)

同一个元素,同一个 99999,我只是在父级上加了/去掉了 transform: translateZ(0),输赢就反转了。

从结果就可以看出z-index 不是全局排名,是上下文内排名 。父级一旦带着 transformopacityfilterwill-change 这类属性,就会创建一个新的层叠上下文,子元素的 99999 只在这个小圈子里有效------而这个圈子整体在根上下文里的排位约等于 0。

通俗一点来说:99999 是班级第一,但整个班在年级里的排位是垫底,隔壁班一个 z-index: 2 就把你们全班压了。

position: fixed 救不了它------fixed 只是换个姿势留在原地,上下文树里的辈分一点没变。想彻底退出战场,只有两条路:去掉父级的 transform 或者 进 Top Layer。

三、Top Layer 的三条实测规则

3.1 dialog 也有两种

showModal() 进 Top Layer,show() 不进。

scss 复制代码
T2 show() 后 :modal 命中: False(非模态不进 Top Layer)
T2b 重叠点(790,555) 最顶层: n-rival ✅ z-index:10 打败了非模态 dialog

非模态打开的 dialog 就是个普通定位元素,z-index: 10 说压就压。所以「用了 dialog 就高枕无忧」是个错觉------dialog 的地位取决于打开方式,不是标签名 。要 Top Layer,必须 showModal()

3.2 Top Layer 内部:按进层顺序排队

多个 Top Layer 元素同时存在时,谁在上面?

答案是最新进层的排最上

复制代码
T4c 反向(先气泡后模态框): 同一点命中=dlg-t ✅ 模态框最新进层,压住气泡

先开的气泡,被后开的模态框直接压住。进层顺序就是栈,后进者居上。

3.3 看得见,点不着?

下面会有一个最反直觉的部分。把顺序反过来:先开模态框,再开气泡------按 3.2 的规则,气泡最新进层,应该在最上面。

测试结果:

ini 复制代码
T4a 先开模态框后开气泡: 重叠点(500,160) 像素=(0, 255, 0)(绿色=气泡视觉在 backdrop 之上), elementFromPoint=dlg-t
    → ✅ 看得见(视觉最新进层在上)却点不着(指针被 backdrop 截走)
T4a-b 点击气泡中心: 按钮被点到=False, 气泡=被 light dismiss 关闭(HTMHell 2025/12 记录的同款无障碍 bug)

分开成几点说:

  • 截图像素采样是纯绿------气泡渲染在 backdrop 之上,能看得清清楚楚;
  • 但同一位置的 elementFromPoint 返回的是模态框,指针事件被 backdrop 整个截走;
  • 在气泡正中间放了按钮再真实点击------按钮的 onclick 没触发,气泡倒是被点空白的 light dismiss 关掉了。

以为的是点到了重试,实际触发的是关闭。

HTMHell 的作者在 2025 年 12 月记录了一模一样的事故:toast 用 popover 实现,模态框开着时后台弹出报错 toast,用户看得见、Tab 不进去、点击永远到不了按钮------键盘和读屏用户直接被锁死。

模态框打开时,模态框之外的整页都是 inert(惰性)状态,视觉层叠救不了交互。气泡在视觉上赢了你,在无障碍上一败涂地。

顺带解开 Vol.01 的一个伏笔:为什么手写「点遮罩关闭」可以用 e.target === dialog 判断?因为 backdrop 上的点击,target 就是对话框元素本身------backdrop 截走的指针事件,全都记在 dialog 头上。两篇的机制在这里接上了。

实操结论:模态框打开前,先把气泡收干净;或者把 toast 挂进 dialog 内部,跟着一起进层。

3.4 白送的还不止这些

还记得《button按钮原生开关弹窗,零 JS 搞定80%交互场景》这篇文章中的代码里的 lastFocused 吗?测试结果:

kotlin 复制代码
T1b 打开后焦点落在: inner-btn(dialog 内第一个可聚焦元素,自动聚焦)
T1b 关闭后焦点归还: focus-open ✅ 自动回到触发按钮,lastFocused 白送

showModal() 打开时自动聚焦弹窗内第一个可聚焦元素,关闭时焦点自动归还触发按钮。手写版最容易漏的两步,原生自带。

再来两个原生自带的:

html 复制代码
<dialog id="dlg-v">
  <form method="dialog">  <!-- 提交即关闭,无需 JS -->
    <button value="ok">好</button>
  </form>
</dialog>
ini 复制代码
T5 form method=dialog 提交: returnValue='ok',弹窗自动关闭 ✅
T6 ::backdrop 计算样式: background=rgba(0, 0, 0, 0.5)

method="dialog" 的表单提交后弹窗自动关闭,用户点的是哪个按钮,dialog.returnValue 里查得到;::backdrop 伪元素能加样式,磨砂玻璃就一行 backdrop-filter: blur(4px) 的事。

四、怎么选?

场景 用什么 理由
强制用户处理的表单/确认 dialog + showModal() Top Layer + inert + 焦点管理
提示气泡/toast/菜单 popover Top Layer + light dismiss,不劫持页面
页内小面板(伴随滚动) show() 或普通定位 不需要抢占,就别进 Top Layer
要盖住一切的自定义层 showModal() / showPopover() 唯一两张门票,z-index 洗洗睡

模态框和 toast 同框时,先收气泡再开框,别让用户看见了但是却点不着。


面试题

Q1:showModal()show() 有哪些区别?

答:至少五条------①Top Layer:只有 showModal 进;②backdrop:只有 showModal 有;③:modal 伪类只命中 showModal;④ESC 关闭与 inert 背景只有 showModal;⑤焦点管理(自动聚焦+关闭归还)是 showModal 白送。一句话:模态能力全部绑定 showModal。

Q2:父元素有 transform,子元素 z-index: 99999 为什么可能输给外部 z-index: 2

答:transform 创建新层叠上下文,子元素的 z-index 只在该上下文内部比较;整个上下文在根层级排位不受子元素影响,所以被外部 z-index: 2 整体压住。逃生方案:去掉父级触发属性,或用 showModal()/showPopover() 进 Top Layer。

Q3:模态框开着时弹出 toast(popover 实现),用户能点到 toast 上的按钮吗?

答:不能。toast 视觉上渲染在 backdrop 之上,但模态框开启时页面其余部分 inert,指针事件被 backdrop 截走------点击只会触发 toast 的 light dismiss,按钮回调不执行。修复:开模态框前关闭所有气泡,或把 toast 放进 dialog 内部。

总结

scss 复制代码
Top Layer(本系列三层弹层的最终答案)
│
├─ 入场券(没有 CSS 属性能进)
│   ├─ dialog.showModal()  → 模态:Top Layer + backdrop + inert + 焦点管理
│   └─ el.showPopover()    → 非模态:Top Layer + light dismiss
│   └─ ⚠️ dialog.show()    → 不进 Top Layer,会被 z-index 打败
│
├─ 为什么 z-index 大战总输
│   ├─ transform/opacity/filter/will-change → 父级封顶
│   └─ z-index 是上下文内排名,不是全局排名
│
├─ Top Layer 内部规则(实测)
│   ├─ 排序:最新进层在最上(栈)
│   └─ ⚠️ 模态框开着时:气泡看得见、点不着(inert + backdrop 截流)
│
└─ 白送清单
    ├─ 焦点自动聚焦 + 关闭归还(Vol.01 的 lastFocused)
    ├─ form method="dialog" + returnValue
    └─ ::backdrop 可加样式(backdrop-filter 磨砂玻璃)

第四篇可以讲一下气泡终于能浮在最上层了,但它该在触发按钮旁边,CSS Anchor Positioning,锚点定位,从此告别手算坐标和 resize 监听。

验证 Demo 与实测脚本已打包,gzh程序员蜡笔熊回复「排位」获取。

欢迎大家评论和指出意见~

相关推荐
deli0071 小时前
CSS 渐变生成器:拖两个颜色直接给代码,做按钮背景不用手写
前端
芳心粽伙饭1 小时前
HTML第八章 表单标签
前端·html
Darling噜啦啦1 小时前
从 SSE 到 LLM 流式输出:搞懂前端实时通信的两种姿势
前端·后端·llm
10年前端老司机2 小时前
别卷CRUD了!前端用Next.js+LangChain.js,低成本冲进AI高薪赛道
前端·langchain·next.js
二进制漫游记2 小时前
ECharts 从入门到实战|前端数据可视化完整教程
前端·信息可视化·echarts
weixin_440730502 小时前
pytest结合allure生成html测试报告(step、story、severity、screenshot)
前端·html·pytest·allure报告
星月日2 小时前
前端上手后端起手式
前端·后端
掘金挖土2 小时前
前端手摸手跑路之 AI 应用开发(一)
前端·后端
用户298698530142 小时前
在 React 中实现 RTF 与 PDF、HTML 的文档转换实践
javascript·react.js·html