如何防止焦点陷阱在模态框外泄露

防止焦点在模态框外泄露的关键是正确建立并维护可控焦点边界,需四步闭环:打开时立即聚焦首个可聚焦元素;Tab键循环限制在模态框内;背景内容设inert或polyfill禁用交互;关闭后将焦点返回触发源或最近可聚焦祖先。

防止焦点在模态框外泄露,关键不是“避免陷阱”,而是**正确建立并维护一个可控的焦点边界**。焦点逃逸本质是边界没封住、初始没捕获、退出没归还——三者缺一不可。

锁定焦点入口:打开时立即接管

模态框显示后,必须在 DOM 渲染完成的下一帧(如用 setTimeout(..., 0)requestAnimationFrame)将焦点设到第一个可聚焦元素上。不能依赖 autofocus 属性,它不可靠且可能被浏览器忽略或与其他组件冲突。

  • 推荐目标:关闭按钮()或首个输入框,确保语义清晰、位置合理
  • 若首个元素被禁用或隐藏,需跳过,动态查找第一个 可见、启用、可聚焦 的元素(可用 getTabables() 工具函数辅助)

围住焦点出口:限制 Tab 循环范围

仅靠初始聚焦不够。用户按 Tab 键时,焦点必须在模态框内循环,不能流到背景按钮、导航栏或页脚。

  • 监听模态框容器的 keydown 事件,只响应 Tab 键(区分 shiftKey
  • 提前收集所有可聚焦子元素:a[href], button:enabled, input:enabled, textarea:enabled, select:enabled, [tabindex]:not([tabindex="-1"])
  • 当焦点在最后一个元素且按

    Tab → 聚焦第一个;在第一个且按 Shift+Tab → 聚焦最后一个
  • 调用 event.preventDefault() 阻止默认跳转行为,这是防止逃逸的强制手段

隔绝外部干扰:让背景内容“失活”

光锁住焦点还不够——背景元素仍可能被点击、被屏幕阅读器读取,甚至意外触发交互。最稳妥的方式是让它们“不可聚焦、不可交互”。

  • 现代方案:给模态框外的根容器(如 或主内容区)添加 inert 属性,浏览器原生支持该语义(Chrome 105+、Firefox 122+、Safari 18.1+)
  • 兼容旧版:引入 inert polyfill(如 Google 提供的),或手动遍历并设置 tabindex="-1" + aria-hidden="true" + 禁用 pointer-events
  • 注意:不要只靠 CSS visibility: hiddendisplay: none,它们不阻止键盘聚焦

收尾不丢焦点:关闭后必须回传

模态框关闭瞬间,焦点若落在 document.body 或丢失,用户会迷失位置,尤其对屏幕阅读器用户极不友好。

  • 打开前记录触发源:const trigger = document.activeElement,或显式传入触发按钮引用
  • 关闭后立即执行 trigger.focus();若触发源已销毁(如按钮被移除),则退回到逻辑上最近的可聚焦祖先(如导航菜单项、页面标题等)
  • React 框架中使用 returnFocus 属性(如 React Focus Lock、MUI、BootstrapVue)可自动处理,但务必显式启用,勿依赖默认值

不复杂但容易忽略——真正起作用的,是这四步闭环:进得来、转得开、出不去、回得去。