Dialog、Sheet、Popover:弹层类组件的可达性与焦点管理
凌晨两点,客户发来一封邮件:“你们网站弹层打开后,按 Tab 键焦点跑到背景页面去了,键盘用户完全没法操作。”
说实话,我看到这封邮件时挺尴尬的——因为这个弹层是我上周刚写的。
我打开代码一看,问题明摆着:Dialog 打开后焦点还留在背景的按钮上,用户按 Tab 键自然就跑到背景页面去了。屏幕阅读器用户更惨——他们根本不知道弹层已经打开,因为焦点没移进去,ARIA 属性也没设置。
这篇就来聊聊 Dialog、Sheet、Popover 这三种弹层组件的可达性和焦点管理。说实话,我踩过的坑,希望你都能绕过去。
先搞清楚:三种组件的核心差异
说实话,很多人——包括我之前——对这三种组件的区别挺模糊的。总觉得不就是弹层吗?都差不多。但实际上它们的核心差异决定了可达性的处理方式。
Dialog(模态对话框)
Dialog 是完全阻断背景交互的模态弹层。
举个例子:你点击”删除订单”按钮,弹出一个确认对话框。这时候背景页面被遮罩层覆盖,你没法点击背景的任何元素——这就是 Dialog 的核心特征:强制用户处理当前任务。
使用场景:
- 重要提示(删除确认、操作警告)
- 表单填写(登录框、注册表单)
- 需要用户立即响应的操作
可达性关键:aria-modal="true" 必须设置,焦点陷阱必须实现。
Sheet(侧边抽屉)
Sheet 是从屏幕边缘滑出的抽屉式面板。本质上它和 Dialog 一样,都是模态弹层——阻断背景交互、需要焦点陷阱。唯一的区别是视觉位置:Sheet 从侧边滑出,Dialog 居中显示。
使用场景:
- 导航菜单(移动端的侧边栏)
- 设置面板(偏好设置、主题切换)
- 详情展示(商品详情、文章预览)
说实话,Sheet 这个词我一开始也挺陌生的。后来发现它就是 Drawer 的另一个名字——有些 UI 库叫 Drawer,有些叫 Sheet,Radix UI 和 shadcn/ui 用的是 Sheet。
可达性关键:和 Dialog 相同,aria-modal="true"、焦点陷阱、Esc 关闭。
Popover(弹出框)
Popover 是不阻断背景交互的非模态弹层。
这点很关键——Popover 打开后,用户还能点击背景元素,焦点不会被强制限制在 Popover 内。
举个例子:你点击一个”更多操作”按钮,弹出一个包含”编辑”、“复制”、“删除”的小面板。这就是 Popover。你可以点击背景的其他按钮,Popover 就自动关闭。
使用场景:
- 下拉菜单(操作菜单、选项列表)
- 工具提示(富文本提示、使用说明)
- 快捷操作(编辑、复制、删除)
可达性关键:aria-modal="false"(或省略),焦点不强制陷阱,点击外部关闭。
一张表看清差异
说实话,这张表我自己写的时候也在对照——之前有些概念确实模糊。
| 特性 | Dialog | Sheet | Popover |
|---|---|---|---|
| 阻断背景 | ✅ 必须阻断 | ✅ 必须阻断 | ❌ 不阻断 |
| 焦点陷阱 | 必须实现 | 必须实现 | 可选(推荐不强制) |
| Esc 关闭 | 必须支持 | 必须支持 | 推荐支持 |
| 点击外部关闭 | 可选 | 可选 | 默认行为 |
| ARIA 角色 | dialog | dialog | popover |
aria-modal | "true" | "true" | "false" 或省略 |
| 视觉位置 | 居中 | 侧边滑出 | 相对触发元素定位 |
一句话总结:Dialog 和 Sheet 是模态弹层,Popover 是非模态弹层。模态弹层必须实现焦点陷阱,非模态弹层可以不强制。
WCAG 可达性标准详解
说实话,WCAG 标准刚开始看挺枯燥的。一堆英文术语,读起来像法律条文。但实际项目中遇到问题后,才发现这些标准真的有用——不是为了应付检查,而是为了让用户能正常操作。
ARIA 属性必填项
弹层组件的 ARIA 属性有三个是必须的:
1. role="dialog"
这个属性告诉辅助技术(屏幕阅读器):这是一个对话框。
<div role="dialog">
<!-- 弹层内容 -->
</div>
2. aria-labelledby
这个属性关联弹层的标题元素。屏幕阅读器打开弹层时,会先朗读标题。
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">确认删除</h2>
<p>此操作不可撤销。</p>
</div>
3. aria-modal="true"(仅模态弹层)
这个属性告诉屏幕阅读器:背景内容不可访问。
<div role="dialog" aria-modal="true">
<!-- 模态弹层内容 -->
</div>
说实话,我之前经常忘记 aria-labelledby。后来用屏幕阅读器测试时才发现——没有这个属性,用户打开弹层后听到的是一片空白,不知道这是什么。
键盘导航要求
WCAG 对弹层的键盘导航有明确要求:
Tab 键:在弹层内循环焦点
用户按 Tab 键时,焦点应该在弹层内的可交互元素之间循环,不能跑到背景页面去。
Shift+Tab:逆向循环焦点
用户按 Shift+Tab 时,焦点反向循环。
Esc 键:关闭弹层
用户按 Esc 键时,弹层应该关闭。这是必须的——有些用户习惯用 Esc 关闭弹层,如果不支持,他们就被困在里面了。
Enter/Space:触发按钮
这两个键用于激活按钮或链接。
说实话,Tab 循环这个我之前踩过坑。弹层打开后,焦点没有限制在弹层内,用户按 Tab 键焦点跑到背景页面去了——这就是我开头提到的客户投诉的问题。
焦点管理规范
焦点管理是弹层可达性最容易被忽视的部分。WCAG 的要求很简单:
打开弹层时:
焦点应该移动到弹层内的第一个可交互元素(通常是关闭按钮或第一个输入框)。
关闭弹层时:
焦点应该恢复到触发元素(打开弹层的按钮)。
说实话,关闭后恢复焦点这个我之前完全没意识到。后来用键盘测试时才发现——弹层关闭后,焦点不知道跑哪去了,键盘用户得重新找。这体验真的很糟糕。
特殊情况:
如果弹层有重要提示内容(比如操作说明),焦点应该先落在容器元素上,让屏幕阅读器先朗读提示内容,再让用户操作。
做法是给容器添加 tabindex="0":
<div role="dialog" aria-modal="true" tabindex="0">
<h2>操作说明</h2>
<p>请仔细阅读以下内容再操作...</p>
<button>确认</button>
</div>
这样弹层打开时,焦点先落在容器上,屏幕阅读器会先朗读整个内容,再让用户 Tab 到按钮。
焦点陷阱实现原理
说实话,焦点陷阱听起来挺复杂的,但原理其实很简单——就是让 Tab 键在弹层内循环。
什么是焦点陷阱
焦点陷阱的定义:将用户的 Tab 导航限制在特定区域内循环。
举个例子:弹层打开后,用户按 Tab 键,焦点从”关闭按钮”跳到”确认按钮”,再按 Tab 键,焦点又回到”关闭按钮”——这就是焦点陷阱。
必要性:防止用户误操作背景内容。如果焦点能跑到背景页面,用户可能不小心触发背景的按钮,导致意外操作。
JavaScript 实现思路
焦点陷阱的核心逻辑很简单:找到弹层内所有可交互元素,监听 Tab 键,在第一个和最后一个元素之间循环。
function trapFocus(modal) {
// 找到所有可交互元素
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// 监听键盘事件
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
// Shift+Tab:在第一个元素时跳到最后
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
// Tab:在最后一个元素时跳到第一个
else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
// Esc 关闭弹层
if (e.key === 'Escape') {
closeModal();
}
});
}
说实话,这段代码我写了好几遍才跑通。主要坑在:
focusableElements的选择器要完整,漏了某个类型焦点就跑出去了e.preventDefault()必须调用,否则浏览器默认行为会把焦点移出去
focus-trap 库介绍
如果不想自己写焦点陷阱,可以用现成的库——focus-trap-react。
import FocusTrap from 'focus-trap-react';
<FocusTrap>
<div className="modal">
<button>关闭</button>
<button>确认</button>
</div>
</FocusTrap>
这个库自动处理焦点循环、Esc 关闭、多层弹层等情况。
说实话,我现在基本不用这个库了——因为 shadcn/ui 内部已经集成了焦点管理。Radix UI(shadcn/ui 的底层)自动处理所有焦点陷阱逻辑,不需要额外引入库。
shadcn/ui 实战:Dialog 实现
说实话,用了 shadcn/ui 之后,我再也不自己手写弹层组件了。不是因为懒,是因为——手写的弹层总是有可达性问题,而 shadcn/ui 基于 Radix UI,自动处理所有可达性细节。
安装与基础使用
npx shadcn@latest add dialog
安装后会自动生成 components/ui/dialog.tsx 文件。
完整代码示例
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
export function DeleteConfirmDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">删除订单</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
<DialogDescription>
此操作不可撤销,确定要删除这条订单吗?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline">取消</Button>
<Button variant="destructive">删除</Button>
</div>
</DialogContent>
</Dialog>
)
}
说实话,这段代码看起来挺简单的,但背后 Radix UI 自动处理了很多细节:
- 打开弹层时焦点移到第一个按钮(“取消”)
- 关闭弹层时焦点恢复到 “删除订单” 按钮
- Tab 键在弹层内循环
- Esc 键关闭弹层
aria-labelledby自动关联DialogTitlearia-describedby自动关联DialogDescription
关键可达性特性
1. 焦点自动管理
Radix UI 的 Dialog 打开时,焦点自动移到弹层内的第一个可交互元素。关闭时,焦点自动恢复到触发元素。
2. ARIA 属性自动关联
DialogTitle 会自动关联 aria-labelledby,DialogDescription 会自动关联 aria-describedby。
<!-- Radix UI 生成的 HTML -->
<div role="dialog" aria-modal="true" aria-labelledby="radix-:r1:" aria-describedby="radix-:r2:">
<h2 id="radix-:r1:">确认删除</h2>
<p id="radix-:r2:">此操作不可撤销...</p>
</div>
说实话,这些细节如果手写很容易遗漏。用 shadcn/ui 完全不用担心。
3. Esc 键自动关闭
按 Esc 键自动关闭弹层,焦点自动恢复到触发元素。
4. 点击遮罩层关闭
点击遮罩层(弹层外的灰色背景)也能关闭弹层。这个行为可以通过 DialogContent 的 onInteractOutside 属性阻止。
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<!-- 点击遮罩层不会关闭弹层 -->
</DialogContent>
shadcn/ui 实战:Sheet 实现
Sheet 和 Dialog 的可达性特性完全相同,唯一的差异是视觉位置——Sheet 从侧边滑出。
安装与基础使用
npx shadcn@latest add sheet
完整代码示例
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
export function NavigationSheet() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">打开菜单</Button>
</SheetTrigger>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>导航菜单</SheetTitle>
<SheetDescription>
选择你想访问的页面
</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-4 mt-4">
<a href="/" className="hover:underline">首页</a>
<a href="/about" className="hover:underline">关于</a>
<a href="/contact" className="hover:underline">联系</a>
</nav>
</SheetContent>
</Sheet>
)
}
与 Dialog 的差异
说实话,Sheet 和 Dialog 的代码几乎一样,只是组件名字不同。主要差异在:
1. 侧边滑出动画
Sheet 默认从右侧滑出,可以通过 side 属性控制方向:
<SheetContent side="left"> <!-- 左侧滑出 -->
<SheetContent side="right"> <!-- 右侧滑出(默认) -->
<SheetContent side="top"> <!-- 顶部滑出 -->
<SheetContent side="bottom"> <!-- 底部滑出 -->
2. 可达性特性相同
Sheet 的可达性特性和 Dialog 完全相同:
role="dialog"aria-modal="true"- 焦点陷阱、Esc 关闭、焦点恢复
说实话,Sheet 这个组件我主要用于移动端的导航菜单。侧边滑出的视觉效果更符合移动端的交互习惯。
shadcn/ui 实战:Popover 实现
Popover 是非模态弹层,和 Dialog、Sheet 的核心差异是:不阻断背景交互。
安装与基础使用
npx shadcn@latest add popover
完整代码示例
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
PopoverTrigger,
} from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
export function ActionPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">更多操作</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>快捷操作</PopoverTitle>
<PopoverDescription>
选择以下操作
</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-2 mt-2">
<Button size="sm">编辑</Button>
<Button size="sm">复制</Button>
<Button size="sm" variant="destructive">删除</Button>
</div>
</PopoverContent>
</Popover>
)
}
关键差异
说实话,Popover 的代码和 Dialog、Sheet 很相似,但背后的行为完全不同:
1. 非模态
Popover 打开后,用户还能点击背景元素。焦点不会被强制限制在 Popover 内。
2. 焦点不强制
用户按 Tab 键时,焦点可以从 Popover 跑到背景元素。这点和 Dialog 完全不同。
3. 点击外部关闭
点击 Popover 外部的任何元素,Popover 会自动关闭。这是默认行为,可以通过 onInteractOutside 属性阻止。
<PopoverContent onInteractOutside={(e) => e.preventDefault()}>
<!-- 点击外部不会关闭 -->
</PopoverContent>
4. 定位灵活
Popover 通过 align 属性控制水平对齐:
<PopoverContent align="start"> <!-- 左对齐 -->
<PopoverContent align="center"> <!-- 居中对齐(默认) -->
<PopoverContent align="end"> <!-- 右对齐 -->
说实话,Popover 我主要用于操作菜单——点击按钮弹出几个快捷操作选项。这种场景不需要阻断背景交互,Popover 正好合适。
高级技巧与常见坑
说实话,弹层组件的坑我踩了不少。这里聊几个最常见的。
焦点恢复的坑:触发元素被删除了
场景:弹层打开后,触发元素(按钮)被删除了。关闭弹层时焦点无处恢复。
解决方案:
- 不要删除触发元素,只是隐藏它
- 或者记录一个恢复焦点的目标元素
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
// 打开弹层时记录触发元素
const handleOpen = (e: React.MouseEvent<HTMLButtonElement>) => {
setTriggerElement(e.currentTarget);
setOpen(true);
};
// 关闭弹层时恢复焦点
const handleClose = () => {
setOpen(false);
triggerElement?.focus();
};
说实话,这个坑我踩过。用户删除一条记录后,弹层关闭,焦点不知道跑哪去了。后来我把焦点恢复到列表的上一条记录才解决。
屏幕阅读器的坑:弹层内容未被朗读
场景:弹层打开后,屏幕阅读器没有朗读弹层内容,用户不知道弹层里是什么。
原因:
- 缺少
aria-labelledby属性 - 焦点没有移到弹层内
解决方案:
确保 DialogTitle 和 DialogDescription 都设置了。shadcn/ui 会自动关联 ARIA 属性。
<DialogContent>
<DialogHeader>
<DialogTitle>确认删除</DialogTitle> <!-- 必须有 -->
<DialogDescription>此操作不可撤销</DialogDescription> <!-- 必须有 -->
</DialogHeader>
</DialogContent>
说实话,我之前经常漏掉 DialogDescription。后来用 NVDA(屏幕阅读器)测试时才发现——没有描述,用户只知道弹层标题,不知道具体内容。
多层弹层的坑:焦点管理混乱
场景:弹层 A 打开弹层 B,关闭 B 后焦点不知道跑哪去了。
解决方案:
Radix UI 的 Dialog 和 Sheet 支持嵌套。关闭内层弹层时,焦点会恢复到内层的触发元素(可能是外层弹层内的按钮)。
<Dialog>
<DialogTrigger>打开弹层 A</DialogTrigger>
<DialogContent>
<DialogTitle>弹层 A</DialogTitle>
<!-- 弹层 A 内打开弹层 B -->
<Dialog>
<DialogTrigger>打开弹层 B</DialogTrigger>
<DialogContent>
<DialogTitle>弹层 B</DialogTitle>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
说实话,多层弹层的情况我尽量避免。实在需要时,我会用 Radix UI 的嵌套支持,让它自动处理焦点。
动画延迟的坑:焦点不在弹层内
场景:弹层有动画(比如淡入),动画期间焦点没有在弹层内。
原因:
动画开始时弹层还没完全显示,焦点设置失败。
解决方案:
Radix UI 自动处理这个问题。动画完成后才设置焦点。
如果自己实现,需要等动画完成:
modal.addEventListener('animationend', () => {
const firstFocusable = modal.querySelector('button, [href], input');
firstFocusable?.focus();
});
说实话,这个坑我踩过。自己写的弹层,打开时焦点还在背景按钮上,因为动画还没完成就尝试设置焦点了。后来加了 animationend 事件监听才解决。
总结
说了这么多,其实核心就三点:
1. 三种组件的核心差异
Dialog 和 Sheet 是模态弹层——阻断背景交互、必须焦点陷阱。
Popover 是非模态弹层——不阻断背景交互、焦点不强制。
2. WCAG 可达性三大要求
ARIA 属性(role="dialog"、aria-labelledby、aria-modal="true")
键盘导航(Tab 循环、Shift+Tab 逆向、Esc 关闭)
焦点管理(打开时聚焦、关闭时恢复)
3. shadcn/ui 自动处理所有细节
Radix UI 自动处理焦点陷阱、ARIA 属性、键盘导航。使用 shadcn/ui,基本不用担心可达性问题。
说实话,写了这么多弹层组件,我现在的原则很简单:生产环境优先用 shadcn/ui。手写弹层组件,可达性问题总是层出不穷,而 shadcn/ui 基于 Radix UI,把这些细节都处理好了。
唯一需要注意的是:理解原理。知道 Radix UI 在背后做了什么,遇到问题时才能快速定位。
参考资料
- WAI-ARIA dialog role - MDN
- Radix UI Accessibility
- WCAG 2.1 Quick Reference
- Mastering Accessible Modals
- focus-trap-react
常见问题
Dialog、Sheet、Popover 三种组件有什么区别?
弹层组件必须实现哪些可达性要求?
什么是焦点陷阱?为什么模态弹层必须实现?
shadcn/ui 的 Dialog 组件自动处理了哪些可达性细节?
• 打开弹层时焦点自动移到第一个可交互元素
• 关闭弹层时焦点自动恢复到触发元素
• Tab 键在弹层内循环
• Esc 键自动关闭弹层
• aria-labelledby 自动关联 DialogTitle
• aria-describedby 自动关联 DialogDescription
弹层关闭后焦点应该恢复到哪里?
弹层有动画时,焦点设置失败怎么办?
如何让屏幕阅读器朗读弹层内容?
15 分钟阅读 · 发布于: 2026年3月29日 · 修改于: 2026年3月29日
相关文章
Tailwind 暗黑模式:class 与 data-theme 两套方案对比
Tailwind 暗黑模式:class 与 data-theme 两套方案对比
用 shadcn/ui 搭建后台骨架:Sidebar + Layout 最佳实践
用 shadcn/ui 搭建后台骨架:Sidebar + Layout 最佳实践
Tailwind 响应式布局实战:容器查询与断点策略

评论
使用 GitHub 账号登录后即可评论