aria-modal="true"不能单独作为焦点围栏,必须配合role="dialog"及手动焦点管理;需缓存可聚焦元素、拦截Tab键循环跳转、打开时聚焦首个元素、关闭时精准回归触发源。

模态框做不好无障碍,不是“体验差一点”,而是会让屏幕阅读器用户完全卡死在背景页、焦点乱跳、甚至无法关闭——aria-modal="true" 单独用基本没用,必须配合手动焦点管理才能落地。
为什么 aria-modal="true" 不能直接当焦点围栏用
这个属性只是告诉辅助技术“这是个模态上下文”,但 Safari(包括 iOS/macOS 所有版本)、旧版 Edge 和部分 NVDA 配置下,它不触发原生焦点围栏。实测中,用户按 Tab 仍会溜出模态框,进入页面 footer 或 header;document.activeElement 也常停留在 body 而非模态框内首个可聚焦元素。
- 必须监听
keydown事件,捕获Tab和Shift+Tab,手动计算并切换焦点 -
aria-modal="true"要和role="dialog"同时存在,缺一不可 - 如果用了原生
<dialog>,别只依赖showModal()—— Safari 17.4 之前不支持其焦点围栏,得 fallback 到 JS 实现
怎么写一个真正可用的焦点围栏逻辑
核心是:拿到模态框内所有可聚焦元素(button、a[href]、input、select、textarea、[tabindex] >= 0),缓存为数组,再在 keydown 中做循环跳转。
- 首次打开模态框后,立刻调用
firstFocusableElement.focus(),不要等动画结束 - Tab 到最后一个元素时,
event.preventDefault()并 focus 第一个;Shift+Tab 到第一个时,focus 最后一个 - 避免用
querySelectorAll('*:focusable')—— 没这个伪类,得手写过滤:Array.from(modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')) - 注意排除
tabindex="-1"元素(它们可被脚本 focus,但不参与 Tab 顺序)
关闭时焦点必须回到触发源,而不是 document.body
很多实现直接 document.body.focus(),这会让屏幕阅读器从头读起,用户完全丢失上下文。正确做法是:在打开模态框前,记下 document.activeElement,或更稳妥地,把触发按钮(比如 id="open-settings-btn")存为引用。
立即学习“前端免费学习笔记(深入)”;
- 关闭模态框后,检查该元素是否还存在于 DOM 且可聚焦,再调用
triggerBtn.focus() - 如果触发源是动态生成/销毁的(如表格行内操作按钮),需用事件委托 +
data-modal-trigger-id属性绑定关系 - 别用
setTimeout(() => triggerBtn.focus(), 0)—— 在 Firefox 中可能失效;改用queueMicrotask(() => triggerBtn.focus())
<dialog> 的兼容性补丁怎么加
想用原生 <dialog> 又要保兼容,就得检测支持性并注入 fallback 逻辑:
- 用
'showModal' in HTMLDialogElement.prototype判断是否支持showModal() - 用
window.getComputedStyle(dialog).display === 'none'辅助判断是否真被隐藏(某些 polyfill 会漏掉样式) - 不支持时,手动设置
dialog.setAttribute('open', ''),再立即运行上面说的焦点围栏 + 焦点回归逻辑 - 遮罩层(
<dialog>自带::backdrop)在 Safari 中不触发pointer-events,需额外加一层div.backdrop并控制显隐
最易被忽略的一点:模态框内部如果有多个 tabindex="0" 的自定义组件(比如封装过的下拉菜单、标签页),它们会破坏你辛苦算好的焦点顺序——得确保这些子组件自己也实现了内部焦点围栏,否则整个链路就断了。



















