实现一个可访问性(a11y)合规的模态框(focus trap + ESC + 滚动锁)

90% 的模态框 / 抽屉组件不合规——键盘用户 / 屏幕阅读器用户用得很痛苦。
正确实现并不复杂,主要是几个细节都必须做对。

一个合规模态必须做到

  1. 打开时焦点自动移入 —— 屏幕阅读器才知道有新内容
  2. 关闭时焦点回到触发按钮
  3. Tab 焦点循环在模态内(focus trap)
  4. ESC 关闭
  5. 背景内容不可滚动(防 iOS 弹起键盘时背景滚走)
  6. 背景 aria-hiddeninert,屏幕阅读器不读背景
  7. 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 库。
精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。