构建无障碍模态框:原生 `<dialog>` 还是第三方库?
无障碍模态框该用原生<dialog>还是库?涵盖焦点管理、inert 背景、ESC 关闭、滚动锁定,以及何时仍需 focus trap。
在 2026 年,默认优先使用原生 <dialog> 元素配合 showModal():它无需任何 JavaScript 即可为你提供焦点限制、ESC 关闭、顶层渲染、背景滚动防止以及隐式 aria-modal——只有当你遇到复杂的层叠需求、丰富的动画要求、设计系统可组合性,或经过实际测量发现原生存在特定缺陷时,才考虑引入 Radix、React Aria、Headless UI 或 a11y-dialog 等库。
这一结论颠覆了多年来的惯常认知。过去的标准建议——将模态框传送到 portal、手动限制焦点、用 aria-hidden 隐藏背景、手动监听 ESC——描述的是 <dialog> 和 inert 尚未落地之前不得不做的事情。<dialog> 元素自 2022 年 3 月起已在所有主流浏览器中达到 Baseline 标准,因此上述大部分脚手架代码如今已成为浏览器默认行为。本文将以具体标准厘清”原生 vs 库”的选择,在大多数教程仍在重复过时建议的焦点捕获问题上给出准确说明,提供经过验证的原生 JS 和 React 最简实现,并以屏幕阅读器的真实表现作为结尾——正确的 ARIA 标注并不能解决所有问题。
核心要点
- 使用
showModal()打开的原生<dialog>会将对话框置于浏览器顶层,并使页面其余部分进入inert状态,因此焦点被限制在页面级模态框内,无需任何 JavaScript 焦点捕获。 - 不要为原生
<dialog>添加 JavaScript 焦点捕获:Tab 键仍可到达的唯一区域是浏览器自身的界面元素(地址栏、标签页、扩展程序),键盘用户应当能够访问这些区域,正如鼠标用户一样。 - 手动焦点捕获适用于自定义的、非
<dialog>模态框——如果你自行构建,背景的inert属性也需要你自己管理,因为没有任何机制会替你处理。 - 最常见的原生陷阱是无声的:如果对话框 DOM 中第一个可聚焦控件位于底部,
showModal()会将焦点移至该控件,导致模态框打开时滚动到底部——解决方法是在源码中将关闭按钮放在标题之后。 - 正确的 ARIA 是必要条件,但并不充分:截至 2026 年,Safari 配合 VoiceOver 仍可能导致
aria-modal对话框内的静态内容无法访问,因此务必在真实屏幕阅读器上进行测试。
无障碍模态框的实际要求
一个无障碍模态框必须具备五项不可妥协的行为:打开时将焦点移入自身、使背景内容不可访问且不可交互、支持通过键盘(ESC)和可见控件关闭、关闭时将焦点还原到触发元素,以及暴露带有无障碍名称的 dialog 角色以便屏幕阅读器正确播报。这些行为直接对应 WCAG 成功标准——焦点顺序(2.4.3)、键盘可操作性(2.1.1、2.1.2)以及名称/角色/值(4.1.2)。完整的 WCAG 2.2 快速参考列出了其余标准;这几条是模态框成败的关键所在。
这些要求之所以容易出错,是因为在手写的 position: fixed 模态框中,每一项都是独立的机制。你需要分别编写焦点移入、焦点还原、ESC 监听、背景隐藏和角色绑定的代码——其中任何一项悄悄失效,都会产生一个对鼠标用户看起来正常、对其他用户却已损坏的模态框。使用原生元素的理由在于,它将上述大部分机制压缩为一次 DOM 调用。
<dialog> 和 showModal() 的免费赠品——以及它们做不到的事
Discover how at OpenReplay.com.
对 <dialog> 调用 showModal(),除了调用本身之外无需任何 JavaScript,即可获得:焦点自动移入对话框、浏览器处理 ESC 键关闭、顶层渲染绕过 z-index 和 portal 的复杂性、可通过 ::backdrop 自定义样式的遮罩层、隐式的 role="dialog" 和 aria-modal="true",以及使背景进入 inert 状态以防止 Tab 键访问后方页面内容。调用 close() 后,焦点会还原到打开对话框的元素。曾为美国 Web 设计系统(U.S. Web Design System)基于 <dialog> 重构模态框的 Jared Cunha 报告称,此次重写将约 400 行 JavaScript 加四个工具函数缩减到了约 38 行——过去需要手写脚本实现的无障碍行为,如今已成为浏览器默认行为。
它不会为你处理的事项及其修复方案:
| 陷阱 | 现象 | 修复方案 |
|---|---|---|
| 第一个可聚焦元素位于 DOM 底部 | showModal() 聚焦该元素,对话框打开时滚动到底部 | 在源码中将关闭按钮放在标题之后;或在 JS 中主动设置初始焦点 |
autofocus 属性 | 在某些桌面浏览器中移动初始焦点时不可靠 | 在 showModal() 之后通过 JS 将焦点设置到目标元素 |
| 点击遮罩层 | 原生对话框默认不会因点击外部而关闭 | 监听 event.target === dialogEl 的点击事件 |
| 背景滚动 | 对话框后方的页面仍可滚动 | 显式锁定滚动(见下文) |
滚动到底部的行为并非某个库的特有问题——W3C ARIA APG 模态对话框示例文档指出,将初始焦点置于对话框内容末尾附近的元素,可能导致对话框打开时滚出视野。最简洁的修复方式是调整源码顺序:标题在前,关闭按钮在后,这样屏幕阅读器会在播报控件之前先播报标题,对话框也会从顶部开始显示。
对于滚动锁定,可以捕获滚动位置、锁定 body,并在关闭时还原:
:root:has(dialog[open]) {
overflow: hidden;
scrollbar-gutter: stable; /* 防止滚动条消失时产生布局偏移 */
}
scrollbar-gutter: stable 会预留滚动条的宽度,避免锁定滚动时页面横向偏移。在 iOS Safari 上,对 body 设置 overflow: hidden 的效果出了名地不可靠;在需要跨平台兼容时,捕获 window.scrollY 并配合负 top 值应用 position: fixed 是更可靠的方案。
焦点捕获问题的定论:不要捕获原生对话框的焦点
不要为使用 showModal() 打开的原生 <dialog> 添加 JavaScript 焦点捕获。浏览器已经将其置于顶层并使页面其余部分进入 inert 状态,焦点已被限制——Tab 键仍可到达的唯一区域是浏览器自身的界面元素(地址栏、标签页、扩展程序),键盘用户应当能够访问这些区域,正如鼠标用户一样。这是模态框教程中被重复最多的过时建议,对于原生元素而言是错误的。
这一观点来自知名无障碍权威人士,由 Zell Liew 在 CSS-Tricks 的《无需为 Dialog 元素捕获焦点》一文中进行了综合阐述。Scott O’Hara 的立场是,WCAG 并未规范性地要求焦点捕获;捕获建议来自信息性指南,写于 inert 和 <dialog> 广泛支持之前,彼时脚本化对话框中捕获焦点比使外部元素不可 Tab 更容易实现。Léonie Watson 的表述是,用户在对话框内应保有与普通页面相同的控制选项——他们应仍能访问浏览器控件。W3C APA 工作组的观点是,原生对话框行为应保持原样,部分原因是在信息亭和受限环境中,Tab 到浏览器界面是一种逃脱机制。
这是一个真实存在的、当前仍在进行的讨论,而非已有定论的规范。上文提到的 Jared Cunha 在 USWDS 工作中仍为原生元素添加了焦点捕获函数,其理由是从业者的谨慎态度。但规范性解释的主流倾向支持原生元素不捕获焦点的立场,而添加捕获会主动剥夺键盘用户默认拥有的能力。
补充说明以完善这条规则:手动焦点捕获属于自定义的、非 <dialog> 模态框——如果你自行构建,背景的 inert 属性也需要你自己管理,没有任何机制会替你处理。“始终捕获焦点”的建议本身并没有错;错在将其应用于已经处理了焦点限制的原生元素上。
最简、正确的原生 <dialog> 实现
以下是原生 JS 版本,附带三项值得添加的增强功能:点击遮罩层关闭、支持减少动效的动画,以及仅此而已。焦点还原已由 close() 处理。
<button id="open">打开对话框</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">确认更改</h2>
<p>您的编辑将立即生效。</p>
<form method="dialog">
<button value="cancel">取消</button>
<button value="confirm">确认</button>
</form>
</dialog>
const dlg = document.getElementById('dlg');
document.getElementById('open').addEventListener('click', () => dlg.showModal());
// 点击遮罩层关闭(原生不提供此功能)。
dlg.addEventListener('click', (e) => {
if (e.target === dlg) dlg.close();
});
aria-labelledby 指向可见标题,为对话框提供无障碍名称。<form method="dialog"> 在提交时关闭对话框,并将被按下的按钮记录在 dlg.returnValue 中——无需额外 JS。遮罩层检测之所以有效,是因为点击 ::backdrop 会注册为对 <dialog> 元素本身的点击,而点击内容区域则注册为对子元素的点击。
为其添加动画时无需与顶层对抗,并尊重用户的动效偏好:
dialog {
opacity: 0;
transition: opacity 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}
dialog[open] { opacity: 1; }
@starting-style {
dialog[open] { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
dialog { transition: none; }
}
@starting-style 和 transition-behavior: allow-discrete 是使进入过渡动画在元素进出顶层时正常工作的关键。prefers-reduced-motion 块满足 WCAG 2.3.3 的要求。
React 版本使用 useRef 调用命令式方法,并使用 useId 实现 SSR 安全的标签关联:
import { useRef, useEffect, useId } from 'react';
function Dialog({ open, onClose, title, children }) {
const ref = useRef(null);
const titleId = useId();
useEffect(() => {
const el = ref.current;
if (open) el.showModal();
else el.close();
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby={titleId}
onClose={onClose}
onClick={(e) => { if (e.target === ref.current) ref.current.close(); }}
>
<h2 id={titleId}>{title}</h2>
{children}
</dialog>
);
}
原生 close 事件(由 ESC、遮罩层处理器或表单提交触发)驱动你的 onClose,使 React 状态与元素保持同步。对于破坏性确认操作,使用 role="alertdialog" 替代默认值,且不要允许点击遮罩层关闭。
原生 <dialog> vs 第三方库:2026 年决策矩阵
使用以下矩阵进行决策。由于补丁版本会随时间变化,集成时请在 npm 上重新核实。
| 选项 | 适用场景 | 权衡 | 当前版本 |
|---|---|---|---|
原生 <dialog> | 大多数模态框;希望零 JS 依赖和内置无障碍支持 | 动画和层叠控制较弱;需自行处理遮罩层关闭 | 自 2022 年 3 月起达到 Baseline |
| a11y-dialog(原生 JS) | 需要经过测试的封装器和事件的原生/非 React 项目 | 依赖 aria-modal;在移动端辅助技术上存在已知限制 | 8.1.5 |
| Radix / shadcn(React) | 设计系统团队;可组合原语;shadcn 工作流 | 增加了原语层和包体积 | 统一的 radix-ui 1.6.0 |
| React Aria(React) | 对无障碍要求严苛、需要 Adobe 行为保证的应用 | API 较陡峭;有较强主张 | @react-aria/dialog 3.5.34 |
| Headless UI(React) | 希望使用配套无样式原语的 Tailwind 项目 | 仅支持 React;自定义(非 <dialog>)实现 | 2.2.10 |
| Base UI(React) | 希望使用 MUI 团队现代原语的新项目 | 生态系统较新 | 稳定版 v1.0(2025 年 12 月) |
a11y-dialog 的最新版本为 8.1.5,根据其官方已知问题文档,它依赖 aria-modal(WAI-ARIA 1.1),在某些移动端辅助技术上的支持并不理想——配合 aria-hidden 辅助工具使用是官方记录的缓解方案。
React 版 Headless UI 目前为 2.2.10 版本,发布于 2026 年 4 月 7 日。根据 Headless UI 文档,其 Dialog 是基于 div 的自定义组件,会将焦点移入并进行捕获——这是正确的,因为它不是原生 <dialog>,所以必须捕获焦点。根据 React Aria 文档,焦点在挂载时移入对话框,在卸载时还原到触发元素,并在打开期间保持限制。有一点需要注意:React Aria 的文档中仍将 HTML <dialog> 元素描述为尚未广泛支持——鉴于该元素自 2022 年起已达到 Baseline 标准,这一表述已经过时,请勿沿用。
无头组件库的格局在 2025 年底至 2026 年初确实发生了真实变化。由 MUI 团队维护、由 Radix、Floating UI 和 Material UI 的前成员构建的 Base UI 于 2025 年 12 月发布了稳定版 v1.0,shadcn/ui 则于 2026 年 2 月将其 Radix 依赖整合到统一的 radix-ui 包中。有一个常被混淆的事实:Radix 的母公司 Modulz 早在 2022 年就被 WorkOS 收购——这已是四年前的旧闻,并非近期动态。真正的近期变化是 Base UI 作为替代原语层的崛起。大多数团队仍通过 shadcn/ui 使用 Radix。
无法使用 <dialog> 时:自定义模态框检查清单
如果你必须构建自定义模态框——例如与其他内容共存的非模态对话框、特殊的层叠需求或遗留约束——你就需要接管原生元素所处理的一切。至少需要:
- 打开时移入焦点,关闭时还原焦点;
- 对应用的其余部分应用
inert属性(比aria-hidden更可靠,因为inert同时将元素从 Tab 顺序中移除); - 实现真正的焦点捕获,使 Tab 和 Shift+Tab 在可聚焦子元素间循环;
- 添加 ESC 键盘事件处理器;
- 锁定背景滚动;以及
- 设置
role="dialog"/alertdialog,配合aria-modal="true"和无障碍名称。
这是”始终捕获焦点”建议正确适用的场景——也是唯一适用的场景。
自定义模态框的无障碍缺陷,正是生产环境会话回放工具能够发现的典型问题,因为它能重现用户实际经历的焦点和键盘操作轨迹。你可以看到键盘用户直接从自定义模态框 Tab 到背景内容——这是缺少 inert 或焦点捕获失效的明确特征。你会看到模态框关闭后焦点落空,这是从未保存触发元素引用的迹象。你会看到完全无法通过键盘关闭的模态框,因为自定义构建时遗漏了 ESC 处理器。这些是审计通过、回放才能发现的失效模式。
屏幕阅读器的真实情况:正确的 ARIA 并不够
正确的 ARIA 是必要条件,但并不充分。截至 2026 年,Safari 配合 VoiceOver 仍可能导致 aria-modal 对话框内的静态非可聚焦内容无法被虚拟光标访问——这是一个已知的 WebKit 问题,Deque 2025 年 12 月关于 ARIA 模态对话框的文章对此有详细记录。实际影响是:一个标注完全正确的对话框,其正文内容仍可能对 VoiceOver 用户不可见。务必进行测试,不要想当然。
此外,NVDA 在某些配置下可能不会播报 dialog 角色,TalkBack 也可能遗漏绝对定位的内容。结论在于流程,而非标注:至少在两种屏幕阅读器/浏览器组合上进行测试——最低限度是 VoiceOver/Safari 和 NVDA/Firefox 或 Chrome——因为自动化审计只能确认属性存在,无法确认真实的辅助技术是否能访问到内容。
结论
优先使用原生 <dialog>,添加四项小增强(遮罩层关闭、主动设置初始焦点、滚动锁定、支持减少动效的动画),并克制住添加不必要焦点捕获的冲动。只有当具体需求——可组合性、动画、层叠——超过零依赖元素自带无障碍支持的价值时,才引入 Radix、React Aria、Headless UI、a11y-dialog 或 Base UI。然后打开屏幕阅读器,确认审计工具无法验证的事情:真实用户是否能够访问到每一个字。
常见问题
原生 dialog 元素能否通过 form method dialog 属性在不使用 JavaScript 的情况下工作?
对话框只有在使用 open 属性时才能不依赖 JavaScript 打开,但这会将其渲染为非模态对话框,没有顶层、没有 inert 背景、也没有焦点限制。模态行为需要在 JavaScript 中调用 showModal(),无法通过声明式方式触发。但你可以在不编写额外 JavaScript 的情况下关闭对话框:在其中放置一个 method='dialog' 的表单,提交该表单即可关闭对话框,并将被按下的按钮记录在 returnValue 属性中。
模态框上的 role dialog 和 role alertdialog 有什么区别?
原生 dialog 元素默认的 role='dialog' 适用于用户浏览或输入内容的通用模态框,而 role='alertdialog' 表示需要用户响应的紧急中断性消息,例如破坏性操作的确认。屏幕阅读器会以更强烈的方式播报 alertdialog 的内容。对于 alertdialog 模态框,不应允许点击遮罩层或随意关闭,因为该模式假定用户必须做出明确选择,而非意外关闭对话框。
为什么我的对话框打开时滚动到了底部而不是顶部?
这是因为对话框 DOM 中第一个可聚焦元素位于内容末尾附近。当 showModal() 执行时,浏览器会将焦点移至第一个可聚焦控件,如果该控件在底部,对话框就会打开并滚动到该位置。W3C ARIA 创作实践指南记录了同样的行为。修复方法是调整源码顺序,使标题在第一个交互控件之前出现,例如将关闭按钮放在标题之后而非之前。
我可以用 :open 伪类而不是 open 属性选择器来为打开的对话框设置样式吗?
:open 伪类可以为打开的对话框设置样式,但它直到 2026 年 5 月 Safari 26.5 发布后才成为新可用的 Baseline 特性。由于它是新可用特性,而非在旧版浏览器中广泛支持,更安全的跨浏览器选择器仍是属性选择器 dialog[open],该选择器自 2022 年 3 月元素达到 Baseline 标准以来一直有效。应将属性选择器作为主要方案,并在支持范围足够广泛之前将 :open 视为渐进增强手段。
Headless UI 是否使用原生 dialog 元素?我是否仍需在其中捕获焦点?
Headless UI 的 Dialog 组件是基于 div 的自定义实现,而非原生 HTML dialog 元素,因此它会正确地将焦点移入并在用户循环可聚焦元素时进行捕获。这与以下规则一致:自定义的非 dialog 模态框必须自行捕获焦点并管理背景的 inert 状态。你不应移除其焦点捕获。不捕获焦点的指导仅适用于使用 showModal() 打开的原生 dialog 元素,在该场景下浏览器已将焦点限制在页面范围内。
Truly understand users experience
See every user interaction, feel every frustration and track all hesitations with OpenReplay — the open-source digital experience platform. It can be self-hosted in minutes, giving you complete control over your customer data.
Star on GitHub12k