90% 的模态框 / 抽屉组件不合规——键盘用户 / 屏幕阅读器用户用得很痛苦。
正确实现并不复杂,主要是几个细节都必须做对。
一个合规模态必须做到
- 打开时焦点自动移入 —— 屏幕阅读器才知道有新内容
- 关闭时焦点回到触发按钮
- Tab 焦点循环在模态内(focus trap)
- ESC 关闭
- 背景内容不可滚动(防 iOS 弹起键盘时背景滚走)
- 背景
aria-hidden或inert,屏幕阅读器不读背景 role="dialog"+aria-modal="true"+aria-labelledby
React 实现
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
interface ModalProps {
open: boolean
onClose: () => void
title: string
children: React.ReactNode
}
export function Modal({ open, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const lastFocusRef = useRef<HTMLElement | null>(null)
// 打开时保存当前焦点 + 把焦点移入对话框
useEffect(() => {
if (!open) return
lastFocusRef.current = document.activeElement as HTMLElement
// 锁背景滚动
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
// 标记背景 inert(modern 浏览器)
const root = document.getElementById('root')
if (root) root.setAttribute('inert', '')
// 找第一个可聚焦元素并聚焦
queueMicrotask(() => {
const focusable = dialogRef.current?.querySelector<HTMLElement>(
FOCUSABLE
)
focusable?.focus()
})
return () => {
document.body.style.overflow = prevOverflow
root?.removeAttribute('inert')
lastFocusRef.current?.focus()
}
}, [open])
// ESC 关闭
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
// Tab focus trap
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !dialogRef.current) return
const els = dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)
if (els.length === 0) return
const first = els[0]
const last = els[els.length - 1]
if (e.shiftKey && document.activeElement === first) {
last.focus()
e.preventDefault()
} else if (!e.shiftKey && document.activeElement === last) {
first.focus()
e.preventDefault()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
if (!open) return null
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal"
onClick={e => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="关闭对话框">×</button>
</div>
</div>,
document.body
)
}
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])'
CSS
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, .5);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.modal {
background: #fff; border-radius: 8px; padding: 24px;
max-width: 480px; width: 90vw; max-height: 90vh; overflow: auto;
position: relative;
}
.modal h2 { margin-top: 0; }
使用
function App() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>打开</button>
<Modal open={open} onClose={() => setOpen(false)} title="确认">
<p>真的要删除吗?</p>
<button onClick={() => setOpen(false)}>取消</button>
<button onClick={handleConfirm}>确认</button>
</Modal>
</>
)
}
inert 属性
inert 是 2024 主流浏览器都支持的属性,给元素加上后该子树:
- 不接受任何焦点
- 不响应任何鼠标点击
- 屏幕阅读器跳过
完美替代过去的 "set tabindex=-1 on every focusable" + aria-hidden
hack。
老浏览器(IE / 老 Safari)回退到 aria-hidden="true" + 手动管理 tabindex,
但 2026 年基本没必要考虑。
用现成的库
实现 1 个模态花一下午是 OK 的,做 5 种弹层组件就别造轮子了。
推荐:
- Radix UI(无样式 primitives)—— 工业级 a11y 实现
- Headless UI(Tailwind 出品)—— 类似
- react-aria(Adobe)—— 更细粒度
直接用上面任何一个,配自己的样式。
测试
npm i -D @axe-core/playwright
import { test } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('modal has no a11y violations', async ({ page }) => {
await page.goto('/')
await page.click('text=打开')
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})
axe-core 是事实标准的 a11y 自动测试工具。
踩过的坑
- 没把焦点还回触发按钮 → 键盘用户按 ESC 后焦点跳到 body 开头,要重新
Tab 几十次回来。 - 用
aria-hidden="true"包裹背景 + 没用 inert:iOS VoiceOver 会跳过
aria-hidden 但 Android TalkBack 仍能 "swipe to" 选中背景元素。
inert两端都靠谱。 - focus trap 实现里查 focusable 元素,querySelector 一次太死板:
对话框内动态变化元素时要每次 keydown 时重新查(如上代码)。 - iOS Safari 上
overflow:hidden锁滚不够稳,需要再加
position: fixed; top: -${scrollY}px。生产建议用body-scroll-lock库。
登录后参与评论。