上一篇讲到了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 不是全局排名,是上下文内排名 。父级一旦带着 transform、opacity、filter、will-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程序员蜡笔熊回复「排位」获取。
欢迎大家评论和指出意见~