切换语言
切换主题

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"(或省略),焦点不强制陷阱,点击外部关闭。

一张表看清差异

说实话,这张表我自己写的时候也在对照——之前有些概念确实模糊。

特性DialogSheetPopover
阻断背景✅ 必须阻断✅ 必须阻断❌ 不阻断
焦点陷阱必须实现必须实现可选(推荐不强制)
Esc 关闭必须支持必须支持推荐支持
点击外部关闭可选可选默认行为
ARIA 角色dialogdialogpopover
aria-modal"true""true""false" 或省略
视觉位置居中侧边滑出相对触发元素定位

一句话总结:Dialog 和 Sheet 是模态弹层,Popover 是非模态弹层。模态弹层必须实现焦点陷阱,非模态弹层可以不强制。


WCAG 可达性标准详解

说实话,WCAG 标准刚开始看挺枯燥的。一堆英文术语,读起来像法律条文。但实际项目中遇到问题后,才发现这些标准真的有用——不是为了应付检查,而是为了让用户能正常操作。

ARIA 属性必填项

弹层组件的 ARIA 属性有三个是必须的:

1. role="dialog"

这个属性告诉辅助技术(屏幕阅读器):这是一个对话框。

<div role="dialog">
  <!-- 弹层内容 -->
&lt;/div>

2. aria-labelledby

这个属性关联弹层的标题元素。屏幕阅读器打开弹层时,会先朗读标题。

&lt;div role="dialog" aria-labelledby="dialog-title">
  &lt;h2 id="dialog-title">确认删除&lt;/h2>
  &lt;p>此操作不可撤销。&lt;/p>
&lt;/div>

3. aria-modal="true"(仅模态弹层)

这个属性告诉屏幕阅读器:背景内容不可访问。

&lt;div role="dialog" aria-modal="true">
  <!-- 模态弹层内容 -->
&lt;/div>

说实话,我之前经常忘记 aria-labelledby。后来用屏幕阅读器测试时才发现——没有这个属性,用户打开弹层后听到的是一片空白,不知道这是什么。

键盘导航要求

WCAG 对弹层的键盘导航有明确要求:

Tab 键:在弹层内循环焦点
用户按 Tab 键时,焦点应该在弹层内的可交互元素之间循环,不能跑到背景页面去。

Shift+Tab:逆向循环焦点
用户按 Shift+Tab 时,焦点反向循环。

Esc 键:关闭弹层
用户按 Esc 键时,弹层应该关闭。这是必须的——有些用户习惯用 Esc 关闭弹层,如果不支持,他们就被困在里面了。

Enter/Space:触发按钮
这两个键用于激活按钮或链接。

说实话,Tab 循环这个我之前踩过坑。弹层打开后,焦点没有限制在弹层内,用户按 Tab 键焦点跑到背景页面去了——这就是我开头提到的客户投诉的问题。

焦点管理规范

焦点管理是弹层可达性最容易被忽视的部分。WCAG 的要求很简单:

打开弹层时
焦点应该移动到弹层内的第一个可交互元素(通常是关闭按钮或第一个输入框)。

关闭弹层时
焦点应该恢复到触发元素(打开弹层的按钮)。

说实话,关闭后恢复焦点这个我之前完全没意识到。后来用键盘测试时才发现——弹层关闭后,焦点不知道跑哪去了,键盘用户得重新找。这体验真的很糟糕。

特殊情况
如果弹层有重要提示内容(比如操作说明),焦点应该先落在容器元素上,让屏幕阅读器先朗读提示内容,再让用户操作。

做法是给容器添加 tabindex="0"

&lt;div role="dialog" aria-modal="true" tabindex="0">
  &lt;h2>操作说明&lt;/h2>
  &lt;p>请仔细阅读以下内容再操作...&lt;/p>
  &lt;button>确认&lt;/button>
&lt;/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';

&lt;FocusTrap>
  &lt;div className="modal">
    &lt;button>关闭&lt;/button>
    &lt;button>确认&lt;/button>
  &lt;/div>
&lt;/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 (
    &lt;Dialog>
      &lt;DialogTrigger asChild>
        &lt;Button variant="outline">删除订单&lt;/Button>
      &lt;/DialogTrigger>
      &lt;DialogContent>
        &lt;DialogHeader>
          &lt;DialogTitle>确认删除&lt;/DialogTitle>
          &lt;DialogDescription>
            此操作不可撤销,确定要删除这条订单吗?
          &lt;/DialogDescription>
        &lt;/DialogHeader>
        &lt;div className="flex justify-end gap-2 mt-4">
          &lt;Button variant="outline">取消&lt;/Button>
          &lt;Button variant="destructive">删除&lt;/Button>
        &lt;/div>
      &lt;/DialogContent>
    &lt;/Dialog>
  )
}

说实话,这段代码看起来挺简单的,但背后 Radix UI 自动处理了很多细节:

  • 打开弹层时焦点移到第一个按钮(“取消”)
  • 关闭弹层时焦点恢复到 “删除订单” 按钮
  • Tab 键在弹层内循环
  • Esc 键关闭弹层
  • aria-labelledby 自动关联 DialogTitle
  • aria-describedby 自动关联 DialogDescription

关键可达性特性

1. 焦点自动管理

Radix UI 的 Dialog 打开时,焦点自动移到弹层内的第一个可交互元素。关闭时,焦点自动恢复到触发元素。

2. ARIA 属性自动关联

DialogTitle 会自动关联 aria-labelledbyDialogDescription 会自动关联 aria-describedby

<!-- Radix UI 生成的 HTML -->
&lt;div role="dialog" aria-modal="true" aria-labelledby="radix-:r1:" aria-describedby="radix-:r2:">
  &lt;h2 id="radix-:r1:">确认删除&lt;/h2>
  &lt;p id="radix-:r2:">此操作不可撤销...&lt;/p>
&lt;/div>

说实话,这些细节如果手写很容易遗漏。用 shadcn/ui 完全不用担心。

3. Esc 键自动关闭

按 Esc 键自动关闭弹层,焦点自动恢复到触发元素。

4. 点击遮罩层关闭

点击遮罩层(弹层外的灰色背景)也能关闭弹层。这个行为可以通过 DialogContentonInteractOutside 属性阻止。

&lt;DialogContent onInteractOutside={(e) => e.preventDefault()}>
  <!-- 点击遮罩层不会关闭弹层 -->
&lt;/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 (
    &lt;Sheet>
      &lt;SheetTrigger asChild>
        &lt;Button variant="outline">打开菜单&lt;/Button>
      &lt;/SheetTrigger>
      &lt;SheetContent side="left">
        &lt;SheetHeader>
          &lt;SheetTitle>导航菜单&lt;/SheetTitle>
          &lt;SheetDescription>
            选择你想访问的页面
          &lt;/SheetDescription>
        &lt;/SheetHeader>
        &lt;nav className="flex flex-col gap-4 mt-4">
          &lt;a href="/" className="hover:underline">首页&lt;/a>
          &lt;a href="/about" className="hover:underline">关于&lt;/a>
          &lt;a href="/contact" className="hover:underline">联系&lt;/a>
        &lt;/nav>
      &lt;/SheetContent>
    &lt;/Sheet>
  )
}

与 Dialog 的差异

说实话,Sheet 和 Dialog 的代码几乎一样,只是组件名字不同。主要差异在:

1. 侧边滑出动画

Sheet 默认从右侧滑出,可以通过 side 属性控制方向:

&lt;SheetContent side="left">   <!-- 左侧滑出 -->
&lt;SheetContent side="right">  <!-- 右侧滑出(默认) -->
&lt;SheetContent side="top">    <!-- 顶部滑出 -->
&lt;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 (
    &lt;Popover>
      &lt;PopoverTrigger asChild>
        &lt;Button variant="outline">更多操作&lt;/Button>
      &lt;/PopoverTrigger>
      &lt;PopoverContent>
        &lt;PopoverHeader>
          &lt;PopoverTitle>快捷操作&lt;/PopoverTitle>
          &lt;PopoverDescription>
            选择以下操作
          &lt;/PopoverDescription>
        &lt;/PopoverHeader>
        &lt;div className="flex flex-col gap-2 mt-2">
          &lt;Button size="sm">编辑&lt;/Button>
          &lt;Button size="sm">复制&lt;/Button>
          &lt;Button size="sm" variant="destructive">删除&lt;/Button>
        &lt;/div>
      &lt;/PopoverContent>
    &lt;/Popover>
  )
}

关键差异

说实话,Popover 的代码和 Dialog、Sheet 很相似,但背后的行为完全不同:

1. 非模态

Popover 打开后,用户还能点击背景元素。焦点不会被强制限制在 Popover 内。

2. 焦点不强制

用户按 Tab 键时,焦点可以从 Popover 跑到背景元素。这点和 Dialog 完全不同。

3. 点击外部关闭

点击 Popover 外部的任何元素,Popover 会自动关闭。这是默认行为,可以通过 onInteractOutside 属性阻止。

&lt;PopoverContent onInteractOutside={(e) => e.preventDefault()}>
  <!-- 点击外部不会关闭 -->
&lt;/PopoverContent>

4. 定位灵活

Popover 通过 align 属性控制水平对齐:

&lt;PopoverContent align="start">  <!-- 左对齐 -->
&lt;PopoverContent align="center"> <!-- 居中对齐(默认) -->
&lt;PopoverContent align="end">    <!-- 右对齐 -->

说实话,Popover 我主要用于操作菜单——点击按钮弹出几个快捷操作选项。这种场景不需要阻断背景交互,Popover 正好合适。


高级技巧与常见坑

说实话,弹层组件的坑我踩了不少。这里聊几个最常见的。

焦点恢复的坑:触发元素被删除了

场景:弹层打开后,触发元素(按钮)被删除了。关闭弹层时焦点无处恢复。

解决方案:

  1. 不要删除触发元素,只是隐藏它
  2. 或者记录一个恢复焦点的目标元素
const [triggerElement, setTriggerElement] = useState&lt;HTMLElement | null>(null);

// 打开弹层时记录触发元素
const handleOpen = (e: React.MouseEvent&lt;HTMLButtonElement>) => {
  setTriggerElement(e.currentTarget);
  setOpen(true);
};

// 关闭弹层时恢复焦点
const handleClose = () => {
  setOpen(false);
  triggerElement?.focus();
};

说实话,这个坑我踩过。用户删除一条记录后,弹层关闭,焦点不知道跑哪去了。后来我把焦点恢复到列表的上一条记录才解决。

屏幕阅读器的坑:弹层内容未被朗读

场景:弹层打开后,屏幕阅读器没有朗读弹层内容,用户不知道弹层里是什么。

原因:

  1. 缺少 aria-labelledby 属性
  2. 焦点没有移到弹层内

解决方案:
确保 DialogTitleDialogDescription 都设置了。shadcn/ui 会自动关联 ARIA 属性。

&lt;DialogContent>
  &lt;DialogHeader>
    &lt;DialogTitle>确认删除&lt;/DialogTitle>  <!-- 必须有 -->
    &lt;DialogDescription>此操作不可撤销&lt;/DialogDescription>  <!-- 必须有 -->
  &lt;/DialogHeader>
&lt;/DialogContent>

说实话,我之前经常漏掉 DialogDescription。后来用 NVDA(屏幕阅读器)测试时才发现——没有描述,用户只知道弹层标题,不知道具体内容。

多层弹层的坑:焦点管理混乱

场景:弹层 A 打开弹层 B,关闭 B 后焦点不知道跑哪去了。

解决方案:
Radix UI 的 Dialog 和 Sheet 支持嵌套。关闭内层弹层时,焦点会恢复到内层的触发元素(可能是外层弹层内的按钮)。

&lt;Dialog>
  &lt;DialogTrigger>打开弹层 A&lt;/DialogTrigger>
  &lt;DialogContent>
    &lt;DialogTitle>弹层 A&lt;/DialogTitle>

    <!-- 弹层 A 内打开弹层 B -->
    &lt;Dialog>
      &lt;DialogTrigger>打开弹层 B&lt;/DialogTrigger>
      &lt;DialogContent>
        &lt;DialogTitle>弹层 B&lt;/DialogTitle>
      &lt;/DialogContent>
    &lt;/Dialog>
  &lt;/DialogContent>
&lt;/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-labelledbyaria-modal="true"
键盘导航(Tab 循环、Shift+Tab 逆向、Esc 关闭)
焦点管理(打开时聚焦、关闭时恢复)

3. shadcn/ui 自动处理所有细节

Radix UI 自动处理焦点陷阱、ARIA 属性、键盘导航。使用 shadcn/ui,基本不用担心可达性问题。

说实话,写了这么多弹层组件,我现在的原则很简单:生产环境优先用 shadcn/ui。手写弹层组件,可达性问题总是层出不穷,而 shadcn/ui 基于 Radix UI,把这些细节都处理好了。

唯一需要注意的是:理解原理。知道 Radix UI 在背后做了什么,遇到问题时才能快速定位。


参考资料


常见问题

Dialog、Sheet、Popover 三种组件有什么区别?
Dialog 和 Sheet 是模态弹层,打开后会阻断背景交互,必须实现焦点陷阱。Popover 是非模态弹层,不阻断背景交互,焦点可以自由移动。核心差异在于是否阻断背景交互。
弹层组件必须实现哪些可达性要求?
WCAG 要求三大方面:ARIA 属性(role="dialog"、aria-labelledby、aria-modal="true")、键盘导航(Tab循环、Shift+Tab逆向、Esc关闭)、焦点管理(打开时焦点移到弹层内、关闭时恢复到触发元素)。
什么是焦点陷阱?为什么模态弹层必须实现?
焦点陷阱是将用户的 Tab 导航限制在特定区域内循环的技术。模态弹层必须实现焦点陷阱,防止用户误操作背景内容。如果焦点能跑到背景页面,用户可能不小心触发背景的按钮,导致意外操作。
shadcn/ui 的 Dialog 组件自动处理了哪些可达性细节?
shadcn/ui 基于 Radix UI,自动处理以下细节:

• 打开弹层时焦点自动移到第一个可交互元素
• 关闭弹层时焦点自动恢复到触发元素
• Tab 键在弹层内循环
• Esc 键自动关闭弹层
• aria-labelledby 自动关联 DialogTitle
• aria-describedby 自动关联 DialogDescription
弹层关闭后焦点应该恢复到哪里?
焦点应该恢复到触发元素(打开弹层的按钮)。这是 WCAG 的明确要求。如果触发元素被删除了(比如删除操作),焦点应该恢复到逻辑上的下一个元素,比如列表的上一条记录。
弹层有动画时,焦点设置失败怎么办?
动画开始时弹层可能还没完全显示,焦点设置会失败。解决方案是等动画完成后再设置焦点,监听 animationend 事件。Radix UI 会自动处理这个问题。
如何让屏幕阅读器朗读弹层内容?
确保 DialogTitle 和 DialogDescription 都设置了。shadcn/ui 会自动关联 aria-labelledby 和 aria-describedby。如果弹层有重要提示内容,可以给容器添加 tabindex="0",让焦点先落在容器上,屏幕阅读器会先朗读整个内容。

15 分钟阅读 · 发布于: 2026年3月29日 · 修改于: 2026年3月29日

评论

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

相关文章