12k
All articles

构建无障碍模态框:原生 `<dialog>` 还是第三方库?

无障碍模态框该用原生<dialog>还是库?涵盖焦点管理、inert 背景、ESC 关闭、滚动锁定,以及何时仍需 focus trap。

OpenReplay Team
OpenReplay Team
构建无障碍模态框:原生 `<dialog>` 还是第三方库?

在 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() 的免费赠品——以及它们做不到的事

<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-styletransition-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.0shadcn/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 元素,在该场景下浏览器已将焦点限制在页面范围内。

Digital experience platform

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

We use cookies to improve your experience. By using our site, you accept cookies.