aria-modal="true" 仅通知屏幕阅读器聚焦范围,不阻止tab键逃逸;必须通过focusin事件配合contains()判断并手动管理焦点才能实现跨浏览器稳定围栏。

aria-modal="true" 本身不锁焦点,别指望它拦 Tab 键
直接说结论:aria-modal="true 只是告诉屏幕阅读器“现在只读这个框”,对浏览器原生 Tab 行为完全没影响。Safari、旧版 Firefox、部分 NVDA 配置下,用户按 Tab 依然会溜到页脚、导航栏甚至 body 上——这不是 bug,是规范本意。
常见错误现象:
- 加了
aria-modal="true"和role="dialog",但 Tab 键一按就跳出弹窗 - 用
<div role="dialog" aria-modal="true"> 替代 <code><dialog></dialog>,结果焦点管理全靠猜 - 以为给弹窗根元素设
tabindex="-1"就能锁住焦点,实际只是让它可被.focus()调用 - 给弹窗根元素设
tabindex="-1",否则首次.focus()可能失败(尤其 SSR 渲染后) - 提前缓存可聚焦元素列表:
modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),别在focusin里实时查 - 只在
focusin中做一次判断:如果!modalEl.contains(e.target),立刻focusable[0]?.focus() - 不要监听
blur或focusout——等它们触发时,焦点已经落在外部元素上了 - Shift+Tab 到第一个元素时同理:检测到
e.target === focusable[0]且e.shiftKey,就focusable[focusable.length - 1].focus() - 不要用
keydown拦所有 Tab——这会破坏 NVDA 的 Insert+F7 列表等辅助功能 - 避免在
focusin里执行 DOM 查询或样式计算,否则可能引发重排卡顿 - 移动端 Safari 在无用户手势前,
.focus()对非原生可聚焦元素静默失败,所以首个元素必须是<button></button>或带tabindex="0"的合法控件 - 打开前记下触发按钮(比如存
data-trigger-id="open-btn"到弹窗 DOM 上) - 关闭时先查该元素是否还在文档流中:
if (triggerEl && triggerEl.offsetParent !== null) - 满足条件才调用
triggerEl.focus();否则 fallback 到主内容区(如main元素) - 别依赖
document.activeElement——弹窗关闭瞬间它常是body,不是你想要的来源
真正起作用的是 focusin + contains() 判断
必须监听弹窗容器的 focusin 事件,靠 modalEl.contains(e.target) 实时判断焦点是否还在内部。这是目前唯一跨浏览器稳定的围栏方式。
实操要点:
Tab 循环不是“跳回开头”,而是拦截逃逸再拉回
所谓“循环”,是指当焦点到达最后一个可聚焦元素后按 Tab,浏览器尝试聚焦下一个全局元素(比如弹窗外的第一个 <button></button>),此时你已在弹窗上监听到 focusin,发现目标不在内部,立刻把焦点塞回第一个元素。
关键细节:
关闭后焦点必须回退到触发源,不是 document.body
很多实现关掉弹窗后直接 document.body.focus(),结果屏幕阅读器从页面顶部重新朗读,用户彻底丢失上下文。这比焦点没锁住还糟。
可靠做法:
复杂点在于:触发元素可能被动态移除、重渲染,或由第三方库管理。缓存 ID 比缓存引用更稳,但得确保 ID 全局唯一且不重复。











