目录
2881 字
14 分钟
<dialog> 与 Popover API:原生弹层不是二选一,而是四种状态机

一、先别问用哪个标签#

一个用 popover 做的登录框,在 Chrome 里看起来没有问题:点按钮打开,点外面关闭,样式也漂亮。换到键盘上走一遍,问题就出来了——按 Tab,焦点从输入框走到了弹层后面的「忘记密码」链接;屏幕阅读器用户则能直接读到页面主体。把它改成 <dialog> 加 showModal(),背景终于被挡住了,但新的问题又来了:点遮罩不再关闭,Esc 的返回值和关闭按钮对不上,原本的退场动画也消失了。

这两次返工都源于同一个提问方式:「用 <dialog> 还是 popover?」 浏览器并不按标签来回答这个问题。它把弹层拆成三件互相独立的事:

  1. 要不要让背景不可操作(模态);
  2. 要不要脱离文档流、进入 top layer;
  3. 关闭请求由谁发起(Esc、点外部,还是只能由代码关)。

把这三件事排一下,会得到四种状态机,而不是两个标签。下面这张表是全文的骨架:

形态进 top layer背景 inertEsc点外部关闭焦点陷阱关闭后焦点
<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 默认不可过渡。所以「打开有动画、关闭瞬间消失」不是浏览器偏心,而是少了三块拼图:

  1. transition-behavior: allow-discrete:允许 display 在过渡结束时再切换;
  2. overlay:让元素在退场动画结束前留在 top layer,不被提前移出;
  3. @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、谁来发起关闭」回答清楚,再选标签,返工会少很多。

浏览器把这三件事拆开,是因为它们本来就可以独立组合。把组合当成标签之争,才是弹层代码越写越长的原因。

参考来源#

<dialog> 与 Popover API:原生弹层不是二选一,而是四种状态机
https://www.hehonglei.cn/posts/dialog-popover-native-overlay-four-state-machines/
作者
Honglei He
发布于
2026-11-09
许可协议
CC BY-NC-SA 4.0