目录
一、先别问用哪个标签
一个用 popover 做的登录框,在 Chrome 里看起来没有问题:点按钮打开,点外面关闭,样式也漂亮。换到键盘上走一遍,问题就出来了——按 Tab,焦点从输入框走到了弹层后面的「忘记密码」链接;屏幕阅读器用户则能直接读到页面主体。把它改成 <dialog> 加 showModal(),背景终于被挡住了,但新的问题又来了:点遮罩不再关闭,Esc 的返回值和关闭按钮对不上,原本的退场动画也消失了。
这两次返工都源于同一个提问方式:「用 <dialog> 还是 popover?」 浏览器并不按标签来回答这个问题。它把弹层拆成三件互相独立的事:
- 要不要让背景不可操作(模态);
- 要不要脱离文档流、进入 top layer;
- 关闭请求由谁发起(
Esc、点外部,还是只能由代码关)。
把这三件事排一下,会得到四种状态机,而不是两个标签。下面这张表是全文的骨架:
| 形态 | 进 top layer | 背景 inert | Esc | 点外部关闭 | 焦点陷阱 | 关闭后焦点 |
|---|---|---|---|---|---|---|
<dialog>.showModal() | 是 | 是 | 是,先 cancel 后 close | 默认否,closedby="any" 可开 | 是 | 还给打开前的元素 |
<dialog>.show() | 否 | 否 | 否 | 否 | 否 | 关闭时尝试还给打开前的元素 |
popover="auto" | 是 | 否 | 是(close request) | 是(light dismiss) | 否 | 焦点在弹层内时还给 invoker |
popover="manual" | 是 | 否 | 否 | 否 | 否 | 没有内建关闭请求,由调用方负责 |
这四行不是同一个 API 的四种写法,而是四种承诺。选错了,后面补的每一行 JS 都在替浏览器补它本来不打算做的行为。
二、top layer:不是 z-index 的替代品
showModal() 和 popover 都会把元素放进 top layer。它不是更高的 z-index,而是一层独立的渲染顺序:
- 它渲染在文档所有 stacking context 之上,
z-index: 9999也盖不住; - 它不受祖先的
overflow: hidden裁剪,也不会被祖先的transform或filter创建的包含块困住; - 它会生成一个铺满视口的
::backdrop伪元素,位于弹层下方、页面内容上方。
但 top layer 不是「脱离 DOM」。字体、颜色、CSS 自定义属性仍然沿 DOM 树继承;祖先上的 color 或 --brand 照样会影响弹层。top layer 改的是渲染层级,不是继承关系。
多个 top layer 元素之间按「谁后显示谁在上」排序,和 z-index 无关。还有一个容易踩的交互:在一个 auto popover 打开时调用另一个元素的 showModal(),auto popover 会被自动关掉;反过来,在已经打开的 popover 上调用 showModal() 会失败,因为模态对话框的语义和 popover 冲突。
三、<dialog>:模态是一种行为,不是一条 CSS
<dialog> 的两种打开方式,差别不在外观,而在浏览器愿意替你承担多少责任。
showModal():把背景交出去
showModal() 会做四件事:把对话框放进 top layer;让对话框之外的文档内容变成 inert(不可点击、不可 Tab、对辅助技术隐藏);把 Tab 循环限制在对话框内部;把焦点移到第一个可聚焦的后代,优先 autofocus。它还会把对话框标记为 aria-modal="true"。
关闭时,浏览器会把焦点还给打开前的元素。Esc 不是直接关闭,而是先触发一个可取消的 cancel 事件,再触发 close:
<dialog id="delete-dialog" closedby="any"> <form method="dialog"> <h2>删除这个分支?</h2> <p>远端分支会被删除,本地提交仍然保留。</p> <div class="actions"> <button value="cancel">取消</button> <button value="delete" class="danger">删除</button> </div> </form></dialog>
<script> const dialog = document.querySelector('#delete-dialog');
dialog.addEventListener('cancel', (event) => { // 正在发请求时,先不让 Esc 把对话框关掉 if (dialog.dataset.busy === 'true') { event.preventDefault(); } });
dialog.addEventListener('close', () => { if (dialog.returnValue === 'delete') { deleteBranch(); } // returnValue 不会自动清空,下次打开前重置 dialog.returnValue = ''; });</script><form method="dialog"> 会把按钮的 value 写进 dialog.returnValue,表单本身不提交。用 cancel 而不是在 keydown 里判断 Escape,是因为关闭请求不只来自键盘:移动端的返回手势、平台的其他 dismiss 动作也会走同一条路径。
closedby:把「点遮罩关闭」变成声明式
模态对话框默认只接受平台关闭请求(Esc、返回手势),点遮罩不会关闭。closedby 属性补上了这个开关:
closedby="none":只能由代码或表单关闭;closedby="closerequest":接受平台关闭请求(showModal()的默认行为);closedby="any":再加上 light dismiss,点遮罩也能关。
要注意支持情况。closedby 是较新的属性,Safari 的支持落后于 Chrome;上线前用 'closedBy' in HTMLDialogElement.prototype 做特性检测,并准备一段点击遮罩的兜底:
if (!('closedBy' in HTMLDialogElement.prototype)) { dialog.addEventListener('click', (event) => { // 只处理鼠标/触摸产生的 click,忽略键盘合成的 click if (event.detail === 0 || event.target !== dialog) return;
const rect = dialog.getBoundingClientRect(); const outside = event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
if (outside) dialog.close('cancel'); });}遮罩本身不是事件目标,点到遮罩时 event.target 仍然是 dialog,所以要用 getBoundingClientRect() 区分「点在对话框的 padding 上」和「点在对话框外」。
show():非模态,且不进 top layer
show() 打开的是非模态对话框:不生成 backdrop,背景不 inert,Esc 默认关不掉,焦点会按 showModal() 的规则移进去,但不会被困住。它也不会进入 top layer——所以祖先的 overflow: hidden 仍然能裁掉它。浏览器会把它暴露为 aria-modal="false"。
它适合的场景很少:一个需要留在文档流里、又希望复用 <dialog> 语义和焦点初始化的面板。把它当 popover 用,通常会同时失去 top layer 和 light dismiss;把它当模态用,又得不到背景隔离。
四、popover:轻量关闭,但不是模态
popover 的核心是「声明式 invoker + 轻量关闭」,它从设计上就不承担模态责任。
<button popovertarget="menu" popovertargetaction="toggle"> 排序方式</button>
<div id="menu" popover> <button type="button">按更新时间</button> <button type="button">按标题</button> <button type="button">按字数</button></div>popover 的默认值是 auto,也可以写成 popover="auto"。三种状态的分工如下:
auto:点外部或按Esc会关闭;通常同一时间只显示一个 auto popover,显示第二个会关掉第一个,嵌套的 auto popover 例外。manual:不响应 light dismiss,也不响应Esc,必须由声明式按钮或 JS 关闭;可以同时显示多个。hint:用于 hover/focus 触发的预览,不会关掉已打开的 auto popover;它是较新的值,写进生产代码前要按目标浏览器做检测。
popover 永远是非模态的:背景不 inert,也没有焦点陷阱。浏览器会做两件轻量的焦点辅助:打开时把下一个 Tab 停靠点移到 popover 里,关闭时(通常是 Esc)把焦点还给 invoker。注意「还给 invoker」的前提是焦点当时还在 popover 内;如果用户已经 Tab 到别处,焦点不会被拽回来。它还会在 invoker 和 popover 之间建立隐式的 aria-expanded 与 aria-details 关系。
这些行为不等于「可以直接当菜单用」。popover 没有隐式的 role;如果你给它加 role="menu",就要同时实现方向键、Home/End、首字母跳转和 roving tabindex,否则屏幕阅读器会宣布一个键盘操作不完整的菜单。更稳妥的做法是先用一组普通按钮,让 Tab 顺序工作:
const menu = document.querySelector('#menu');
menu.addEventListener('toggle', (event) => { if (event.newState === 'open') { menu.querySelector('button')?.focus(); }});还有一个容易被忽略的副作用:在一个 auto popover 打开时调用 showModal(),popover 会被关掉。这不是 bug,而是「模态对话框应该独占交互」的规则在起作用。
五、退场动画:display 是离散的
<dialog> 和 popover 在隐藏时都切换 display: none,而 display 默认不可过渡。所以「打开有动画、关闭瞬间消失」不是浏览器偏心,而是少了三块拼图:
transition-behavior: allow-discrete:允许display在过渡结束时再切换;overlay:让元素在退场动画结束前留在 top layer,不被提前移出;@starting-style:给「从display: none进入可见状态」的那一刻提供起始值。
dialog,[popover] { opacity: 0; transform: translateY(8px) scale(0.98); transition: opacity 180ms ease, transform 180ms ease, display 180ms allow-discrete, overlay 180ms allow-discrete;}
dialog[open],[popover]:popover-open { opacity: 1; transform: none;}
/* 只负责进入方向;退场就是回到上面的关闭态 */@starting-style { dialog[open], [popover]:popover-open { opacity: 0; transform: translateY(8px) scale(0.98); }}
dialog::backdrop,[popover]::backdrop { background: rgb(0 0 0 / 0); transition: background 180ms ease, display 180ms allow-discrete, overlay 180ms allow-discrete;}
dialog[open]::backdrop,[popover]:popover-open::backdrop { background: rgb(0 0 0 / 0.4);}
@starting-style { dialog[open]::backdrop, [popover]:popover-open::backdrop { background: rgb(0 0 0 / 0); }}
@media (prefers-reduced-motion: reduce) { dialog, [popover], dialog::backdrop, [popover]::backdrop { transition: none; }}这段 CSS 要当成渐进增强来看。@starting-style 和 transition-behavior: allow-discrete 已经进入 Baseline,但 overlay 目前主要只有 Chromium 系支持;Firefox 对 display 的离散过渡也有已知问题。结果是:支持不全的浏览器会直接关闭,功能不受影响,只是少了动画。不要让关闭逻辑依赖 transitionend,否则在那些浏览器里弹层会关不掉。
六、选型清单
把需求翻译成状态机,比记标签更有用:
- 确认框、登录框、支付面板:需要背景隔离和焦点陷阱 →
<dialog>.showModal(),再加一个显式关闭按钮。Esc是快捷键,不是唯一的退出方式。 - 下拉菜单、日期选择器、筛选面板:需要 top layer,但用户还要能操作背景 →
popover="auto"。点外部关闭符合预期;如果面板里有未保存的输入,改用manual并自己决定何时关闭。 - 悬浮提示、预览卡:
popover="hint"是方向,但要先确认目标浏览器支持;不支持时退回auto或普通 tooltip。 - 常驻侧栏、可拖动的检查器:不需要 top layer,也不该挡住页面 → 普通定位元素,或者
<dialog>.show();不要为了「原生」而选错层级。 - 不要用 popover 做模态:背景仍可点击、屏幕阅读器仍能读到主体,焦点也不会被困住。补
inert和焦点陷阱,等于手写一遍showModal()已经做好的事。 - 不要用
<dialog>.show()做 popover:它既没有 light dismiss,也不进 top layer,最后往往要补更多代码。
最后几条检查项,建议在合并前过一遍:
- 模态对话框有显式关闭按钮,
Esc只是补充; - 有未保存内容时,在
cancel里拦截关闭请求; - 打开后焦点落在合适的控件上(
autofocus或toggle里手动focus()); - 关闭后焦点回到触发元素,且没有把用户已经移走的焦点拽回来;
- popover 没有冒充
role="menu",除非方向键和 roving tabindex 都实现了; -
closedby做了特性检测,Safari 下点遮罩仍能关闭; - 退场动画在
prefers-reduced-motion下关闭,且关闭逻辑不依赖动画结束事件; - 用键盘和屏幕阅读器各走一遍,而不只是用鼠标点一遍。
七、结语
原生弹层省掉的是行为代码,不是设计决策。showModal() 替你做了背景隔离、焦点陷阱和关闭请求;popover 替你做了 top layer、light dismiss 和 invoker 关系;show() 几乎什么都没替你决定。先把「要不要模态、要不要 top layer、谁来发起关闭」回答清楚,再选标签,返工会少很多。
浏览器把这三件事拆开,是因为它们本来就可以独立组合。把组合当成标签之争,才是弹层代码越写越长的原因。