前阵子帮一个后台管理系统做无障碍审计,工具跑完给了一份列表,红字最多的两页,都栽在同一个地方——弹窗。审核意见写得很直白:焦点跑到了弹窗外面、Esc 键关不掉、关闭之后焦点丢了、背景内容还能用键盘 Tab 到。
这些弹窗都是自己用 div 加上绝对定位写的,加上 z-index 堆得老高,看起来跟原生的没差。但无障碍工具一测就现原形。改的时候才发现,手写弹窗的那些坑,全部都是浏览器本来就帮你处理好的事——只是我们一直没用原生的 <dialog>。
这篇把 dialog 的相关新特性翻一遍,做一套真正能用在实际项目里的原生模态体系。
先说说手写弹窗到底要处理多少事
为了有个对照,先把手写弹窗要关心的东西列一遍:
- 键盘焦点必须锁在弹窗内,Tab 到最后一个元素要跳回第一个
- 弹窗打开时,背景内容要挡住交互,阻止屏幕阅读器读到
- Esc 键关闭,但要不要关、开启关闭之前要做什么校验,都得自己写
- 关闭之后焦点得还给触发元素,不然键盘用户会”找不着北”
- 点击遮罩关闭,但点击弹窗内部不能误触
- 弹窗打开时禁止 body 滚动,关闭时恢复,还要处理滚动位置偏移
- 多个弹窗叠加时的层级和关闭顺序
- 弹窗内容动态变化时,焦点边界也要跟着更新
列表写到这里,就能理解为什么很多团队都用一个几百行的第三方弹窗库了。问题是,这些事浏览器本来就会做。
dialog 元素的三种状态
<dialog> 默认是一个隐藏的元素,它有三种状态:
<dialog id="confirm">
<p>确定要删除这条记录吗?</p>
</dialog>
第一种是关闭状态。元素带 display: none,不占位,不参与无障碍树。
第二种是非模态打开,靠 open 属性:
<dialog open>...</dialog>
或者 dialog.show()。所谓”非模态”就是它只是显示出来,不会把背景挡掉,用户仍然可以操作页面其他部分。这种模式适合做浮动面板、引导提示一类的轻量场景。
第三种是模态打开,必须用 showModal():
const dlg = document.getElementById('confirm')
dlg.showModal()
模态打开是 dialog 真正厉害的地方。它一次性做了几件事:把弹窗放到顶层(top layer),自动加一个半透明背景遮罩 ::backdrop,把焦点移到弹窗内部第一个可聚焦元素,给弹窗外的所有内容加上 inert 效果——也就是背景彻底不可交互、屏幕阅读器跳过、鼠标点击也无效。
以前我们为了模拟这个 inert 效果,写过一个”监听 tab 键手动兜底焦点”的函数,代码一百多行,还有边界 bug。用 dialog 就是一行 showModal()。
::backdrop 和样式钩子
遮罩层由浏览器提供,通过 ::backdrop 伪元素定制:
dialog::backdrop {
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
}
有一点必须知道:::backdrop 的样式随着元素进入 top layer 才存在。你写 dialog::backdrop { background: red },弹窗关闭时这条规则是不匹配的。这也意味着你不能通过 ::backdrop 做”淡出动画”,因为关闭的那一刻它就不存在了。
顺带提一句 @starting-style,用来做入场动画的:
dialog[open] {
opacity: 1;
transform: scale(1);
transition: opacity 0.2s ease, transform 0.2s ease, display 0.2s allow-discrete;
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.96);
}
}
关键在 transition 里那个 display 0.2s allow-discrete。display 本身是个不能过渡的属性,但是加了 allow-discrete 之后,让浏览器在过渡结束的那一刻才切换它的值,从而让整个 opacity 过渡有地方播放。不加这句,关闭的时候弹窗会瞬间消失,入场动画白做。
入场动画这块内容不少,展开讲就跑偏了,这里点到为止。真正撑起这套体系的是下面几个属性。
closedby:让 Esc 和点击遮罩的行为可配置
模态弹窗默认就有两个关闭方式:Esc 键,以及点击 ::backdrop 区域。以前很多项目想要”点击遮罩不关闭”(比如要求用户必须做出选择才能继续),只能监听 click 事件判断点击坐标,代码长且脆。
现在一个属性搞定:
<dialog id="must-choose" closedby="none">
<p>请选择一项操作后再继续。</p>
<button value="ok">我知道了</button>
</dialog>
closedby 有三个值:
any:Esc 和点击遮罩都能关,模态弹窗的默认值closerequest:只允许 Esc 关闭,点击遮罩无效none:两个都不行,必须通过弹窗内的按钮关闭
有一种场景经常用到 closerequest:弹窗里有用户填了一半的表单,点击遮罩很容易误触导致数据丢失,但 Esc 是一个明确的”我要关闭”的操作,允许保留。这个分寸感用之前的手写方案很难拿捏,现在一个值就够了。
还有一种更细粒度的控制,通过监听 cancel 事件来实现:
dialog.addEventListener('cancel', e => {
if (formDirty()) {
// 阻止 Esc 关闭,改为弹一个二次确认
e.preventDefault()
showSaveConfirm()
}
})
cancel 事件在浏览器准备关闭弹窗时触发,此时 preventDefault() 就能把关闭动作拦下来。这是数据丢失防护的核心写法。过去手写弹窗做不到这么优雅——要么是拦不住,要么是拦住了但关不掉之后的流程没设计。
command 与 commandfor:声明式控制
以前打开弹窗必须写 JS:
<button id="open-btn">打开</button>
<dialog id="my-dialog">...</dialog>
<script>
document.getElementById('open-btn').addEventListener('click', () => {
document.getElementById('my-dialog').showModal()
})
</script>
有了 command 和 commandfor,这两行 JS 可以彻底省掉:
<button commandfor="my-dialog" command="show-modal">打开</button>
<dialog id="my-dialog">
<button commandfor="my-dialog" command="close">关闭</button>
</dialog>
commandfor 指向目标的 id,command 说明要做什么动作。目前支持的值有:
show-modal:模态打开show-popover/hide-popover/toggle-popover:控制 popoverclose:关闭最近的 dialogrequest-close:请求关闭,走和 Esc 一样的取消流程,可被cancel事件拦截show/hide/toggle:控制非模态 dialog
close 和 request-close 的区别很重要。close 是直接关,跳过 cancel 事件;request-close 会先走一遍取消流程。如果需要”关闭前先确认”这类逻辑,必须用 request-close,否则 cancel 事件根本不触发。
这个属性的价值不只是省代码,更重要的是让触发关系写在 HTML 里。以前打开弹窗的按钮和弹窗本身经常不在同一个模块里,靠 JS 手动查找元素,重构的时候一改 id 就崩。现在 HTML 里明明白白写着”这个按钮负责打开那个弹窗”,一眼就能看清楚。
对无障碍尤其有帮助:屏幕阅读器可以读出这两个元素之间的关系,不再需要手写 aria-haspopup 和 aria-controls。
inert:让背景彻底”隐身”
showModal() 会自动给弹窗之外的交互内容加 inert 效果,这一点前面提过。但有一种情况需要手动加:非模态弹窗。
比如做一个侧边抽屉,用户可能希望抽屉打开时还能滚动主内容看上下文——这时候用非模态是对的。但抽屉本身如果遮挡了某个按钮,用户 Tab 过去会引发困惑,就可以针对性地给那个按钮加 inert:
<button id="danger-action" inert>删除账号</button>
<script>
drawer.addEventListener('close', () => {
document.getElementById('danger-action').removeAttribute('inert')
})
</script>
inert 是个布尔属性,有两种常见用法。第一种是像上面这样写在 HTML 里,运行时通过 JS 切换;第二种是作为全局属性,动态控制整个区域的启用状态(比如表单提交中禁止操作)。
被 inert 包裹的内容,表现包括:不能点击、不能聚焦、从无障碍树里移除、也不能被搜索/翻译之类的功能命中。这几点都比简单的 pointer-events: none 更彻底——后者只是挡住了鼠标,键盘用户依然能 Tab 进去,屏幕阅读器也照样读出来,属于”假禁用”。
顺带说一句:<dialog>、<fieldset disabled>、<input disabled> 这些原生机制本身就带有类似 inert 的行为。能用一个属性解决的,不要自己去实现一层。
案例一:带校验的删除确认框
先把前面几块拼成一个完整的确认框:
<dialog id="del-confirm" closedby="closerequest" aria-labelledby="del-title">
<form method="dialog">
<h2 id="del-title">删除确认</h2>
<p>删除后无法恢复,确定继续吗?</p>
<div>
<button value="cancel">取消</button>
<button value="confirm">确认删除</button>
</div>
</form>
</dialog>
几个点在代码里藏着。
<form method="dialog"> 让表单提交自动关闭弹窗,关闭时 dialog.returnValue 会被设成提交按钮的 value。这是 dialog 里最容易被忽略但最实用的技巧——不用在按钮上绑点击事件了。
const dlg = document.getElementById('del-confirm')
const trigger = document.getElementById('delete-btn')
trigger.addEventListener('click', () => {
dlg.showModal()
})
dlg.addEventListener('close', () => {
if (dlg.returnValue === 'confirm') {
actuallyDelete()
}
// 关闭后把焦点还给触发按钮
trigger.focus()
})
焦点归还这一步不能省。虽然浏览器在关闭 dialog 时会尝试把焦点还给打开它的元素,但如果触发用的是 commandfor,或者关闭之后触发按钮被重新渲染成了别的 DOM 节点,焦点就可能落到 body 上。手动 focus() 一次,稳。
closedby="closerequest" 这个选择是刻意的。删除操作点击遮罩关闭很容易让人以为”点错了没删”,但实际上正在关闭,反而容易造成误操作。用 Esc 关闭更明确一些。
还有一个细节:这里写了 aria-labelledby 指向标题元素。<dialog> 本身没有隐式的标题关联,屏幕阅读器不会自动读出标题,得手动指定。这是无障碍审计里高频的扣分项,值得养成习惯。
按钮里的 <button> 没写 type="button",也没有问题——在 <form method="dialog"> 里,默认行为就是提交并关闭。但如果这个表单外面还叠了别的表单结构(比如弹窗里再嵌一个 form),那就得显式写 type,避免顺序搞混。
案例二:带表单的对话框,提交后不关闭
第二种常见需求:弹窗里有表单,提交时要校验,校验失败的话不能关掉,得留在原地给用户提示。
<dialog id="profile-edit">
<form id="profile-form">
<h2>编辑个人资料</h2>
<label for="nickname">昵称</label>
<input id="nickname" name="nickname" required maxlength="20">
<label for="bio">简介</label>
<textarea id="bio" name="bio" maxlength="200"></textarea>
<div>
<button type="button" commandfor="profile-edit" command="close">取消</button>
<button type="submit">保存</button>
</div>
</form>
</dialog>
<script>
const form = document.getElementById('profile-form')
const dlg = document.getElementById('profile-edit')
form.addEventListener('submit', async (e) => {
e.preventDefault()
const fd = new FormData(form)
const payload = Object.fromEntries(fd)
// 提交过程中禁用再次点击
const submitBtn = form.querySelector('[type="submit"]')
submitBtn.disabled = true
try {
await saveProfile(payload)
dlg.close()
} catch (err) {
showInlineError(form, err.message)
submitBtn.disabled = false
}
})
</script>
这里有几个关键点。
表单不写 method=”dialog”。写了的话点保存会立即关闭弹窗,跳过我们的校验和提交逻辑。取消按钮改用 command="close" 显式关闭。
preventDefault 是必须的。哪怕不用 dialog 的提交行为,浏览器也会尝试用传统方式提交,页面会刷新。很多项目里弹窗表单提交一次页面跳走,就是这个原因。
提交中禁用按钮。这一步平时不太注意,但用户很容易在保存等待期间连点几次,导致重复提交。禁用按钮比加 loading 图标更彻底。
错误信息写在表单内部。不用全局 toast 去提示,因为焦点在弹窗里,toast 可能在页面顶部看不到。更稳妥的做法是把错误信息插到对应输入框下方,并把焦点移到出错字段上。
function showInlineError(form, message) {
let tip = form.querySelector('.form-error')
if (!tip) {
tip = document.createElement('p')
tip.className = 'form-error'
tip.setAttribute('role', 'alert')
form.prepend(tip)
}
tip.textContent = message
// 把焦点移到出错的位置
const firstInvalid = form.querySelector('[aria-invalid="true"]')
if (firstInvalid) firstInvalid.focus()
}
role="alert" 让屏幕阅读器在错误出现时立即朗读出来,键盘用户不用自己去翻找错误提示在哪里。
案例三:侧边抽屉
抽屉类组件要不要用模态,取决于产品需求。如果打开抽屉时用户还能操作主页面,用非模态更合适:
<dialog id="side-drawer" popover>
...
</dialog>
等等,这里用了 popover 而不是 <dialog> 的模态能力——这是另一个选择。
抽屉场景其实有两条路。
路线一:用 <dialog> 的非模态模式。通过 show() 打开,此时没有遮罩,背景可交互。适合需要在抽屉里做操作同时参照主内容的情况。
<dialog id="filter-drawer">
<h2>筛选条件</h2>
<input type="checkbox" id="in-stock">
<label for="in-stock">仅看有货</label>
<button commandfor="filter-drawer" command="close">收起</button>
</dialog>
<script>
document.getElementById('open-filter').addEventListener('click', () => {
document.getElementById('filter-drawer').show() // 非模态
})
</script>
路线二:用 popover。popover 天生就是悬浮面板的语义,不需要 show(),通过 popovertarget 或 command="toggle-popover" 控制。它的好处是自动有”点击外部关闭”的行为,响应速度也更快。
怎么选,看两点。第一,如果面板里要做表单交互,用户可能在里面停留一段时间,用 dialog 更稳;如果只是展示一段说明,用 popover。第二,如果需要多个抽屉同时打开(比如一个筛选一个详情),popover 的”轻量关闭”会干扰,得设成 popover="manual",这时候 dialog 反而更合适。
两者在层级上都走 top layer,不受 overflow: hidden 的裁剪,这一点对抽屉很关键——侧边栏如果挂在某个卡片下面,用普通的 position: fixed 会被裁掉。
几个踩过的坑
1. 页面加载时 dialog 一闪而过
有人为了调试方便,直接在 HTML 里写了 <dialog open>,然后在 JS 里调 showModal()。这在慢网络下会导致弹窗先以非模态形式闪一下,然后才变成模态。
正确做法是不要在 HTML 里写 open,全部通过 showModal() 或 command="show-modal" 控制。
2. 嵌套 dialog 时的层级
从 dialog 里再打开一个 dialog,新的会叠加在上面,这是浏览器行为,没问题。但要注意:关闭内层 dialog 不会自动恢复外层的焦点。外层如果监听 close 事件去处理后续动作,这个时候可能会触发两遍(一次是内层关闭,一次是外层真正关闭)。加分判断:
outerDlg.addEventListener('close', e => {
// 只有外层自己关闭才处理
if (e.target !== outerDlg) return
...
})
3. 弹窗打开期间的滚动问题
原生 modal dialog 会自动阻止背景滚动,不需要写 overflow: hidden。但有一个例外:如果弹窗内容本身高度超过视口,需要滚动,默认它会溢出。要给它加滚动样式:
dialog[open] {
max-height: 85vh;
overflow: auto;
}
用 vh 还是 dvh?移动端建议用 dvh,因为地址栏显示/隐藏时 vh 是不变的,会导致弹窗底部被挡住。dvh 跟着视口变化,虽然会有一点抖动,但内容不会丢。
4. 表单提交后自动关闭的坑
前面提到 <form method="dialog"> 会在提交时关闭弹窗,但有一个隐藏行为:提交过程中如果 close 事件被 preventDefault() 拦下,弹窗会关闭失败,但 returnValue 已经被设置了。下次打开时 returnValue 还是上一次的值,容易误判。
稳妥做法是在 close 事件处理完之后,手动重置一次:
dlg.addEventListener('close', () => {
const val = dlg.returnValue
// 用完立刻清掉,避免下次打开误读
dlg.returnValue = ''
handleResult(val)
})
5. command 属性对旧浏览器的兼容
command 和 commandfor 是最近才落地的,稍旧一点的浏览器不认识。所以如果目标是广覆盖,别把功能完全押在这个属性上。
降级方式:对不支持的环境,用 JS 手动接管按钮的点击,行为跟 command 保持一致。判断方式是 'command' in HTMLButtonElement.prototype,或者更粗暴一点,直接在 HTML 上加一个 data-fallback="open" 之类的标记,然后在启动脚本里扫一遍。
降级怎么写
dialog 元素本身的支持度已经非常广,基本不用担心。真正需要判断的是 closedby、command、@starting-style 这几个新东西。
@supports (command: show-modal) {
/* 用 command 属性实现 */
}
@supports (closedby: none) {
/* 靠 closedby 控制关闭行为 */
}
@supports (transition-behavior: allow-discrete) {
/* 关闭时也能淡出 */
}
对不支持 closedby 的环境,用 JS 兜底:监听 click 事件,判断是不是点在了 backdrop 上:
dialog.addEventListener('click', e => {
// click 事件的 target 是 dialog 本身,说明点在遮罩上
if (e.target !== dialog) return
if (dialog.dataset.closedby === 'none') return
dialog.close()
})
这个判断之所以成立,是因为 dialog 内部的所有内容都在子元素里,点击内部时 e.target 是子元素。点击 backdrop 时事件会冒泡到 dialog,此时 e.target 恰好是 dialog 本身。
但这种兜底和原生 closedby 的行为有细微差别:JS 版本没法区分”点击遮罩关闭”和”点击对话区域内的空白处”,而原生版本能精确识别。所以能用原生尽量用原生,兜底只是给旧环境一条活路。
另外提示一点:closedby 只对模态 dialog 生效。非模态 dialog 本身没有遮罩,这个属性没有意义。
把这套体系落到项目里
给团队做规范的时候,我列了几条硬性约定:
- 所有弹窗必须用
<dialog>,不允许手写 div 模拟 - 模态场景必须用
showModal(),不用show() - 打开按钮优先用
command="show-modal",只有在需要携带参数时 - 才写 JS
- 关闭前的校验通过
cancel事件拦截,不用别的方式 - 弹窗必须有
aria-labelledby指向标题 - 关闭后必须调
focus()把焦点还给触发元素 - 不允许在 HTML 里写
open,避免闪屏
这几条定下来之后,弹窗相关的无障碍问题基本清零。以前每次审计都得改一遍的地方,现在从源头就没有了。
从成本上说,这套方案的手写代码量比引入一个弹窗库要小得多——无非就是模板里多写几个属性。但它换回来的东西是实打实的:焦点、遮罩、Esc、无障碍语义,全部由浏览器负责,不用担心实现漏了某个边界。
最后
回头看,<dialog> 这个元素被冷落了很久。它在 2013 年就有了,能真正用起来是最近几年的事——随着 closedby、command、@starting-style 这几个配套特性落地,它终于从”勉强能用”变成了”比手写更好用”。
如果项目里现在还在用 div 拼弹窗,建议挑最简单的那个先换成 <dialog> 试试。改完之后打开开发者工具的无障碍面板看一眼,就能感受到差别——那把原来要写一百多行 JS 才能填上的漏洞,一行 showModal() 就修补了。

