焦点锁定必须通过tabindex="-1"+focus()+键盘拦截三件套实现,单靠tabindex无法限制焦点范围,且需注意ios safari手势限制和inert属性的现代替代方案。

弹出层(如模态框、下拉菜单、抽屉)打开时,焦点必须被限制在内部,否则键盘用户会意外跳到背景内容——tabindex本身不实现焦点锁定,它只是配合 JavaScript 实现这一行为的必要基础。
为什么不能只靠 tabindex="0" 或 tabindex="1"
tabindex 只控制单个元素是否可聚焦、是否入流、相对顺序,它不提供“区域锁定”能力。设 tabindex="0" 让弹出层容器可聚焦,只是让它能被 Tab 进去;设 tabindex="1" 更危险:它会把整个弹窗强行插到 Tab 流最前面,但背景里所有 button、input 仍保留在流中,焦点一不小心就漏出去。
- 正整数
tabindex会破坏自然流,且多个正数时浏览器按升序排,但原生可聚焦元素全被挤到最后,形成逻辑断层 -
tabindex="0"的容器获得焦点后,按 Tab 仍会照常走到其内部第一个可聚焦子元素——这没问题,但下一步就可能跳出容器 - 真正要防的是“跳出”,不是“进不来”,所以单靠 HTML 属性无法完成
焦点锁定的最小可行组合:tabindex="-1" + focus() + 键盘拦截
标准做法是三件套缺一不可:tabindex="-1" 赋予容器编程聚焦能力,.focus() 主动聚焦,再用 keydown 拦截 Tab 键做循环聚焦。
- 弹出层根元素加
tabindex="-1"(例如<div class="modal" tabindex="-1">),确保它能被 <code>.focus()调用 - 打开时立即执行
modalEl.focus(),让焦点落在容器上(视觉上可能不明显,但为后续拦截打基础) - 监听
keydown事件,当event.key === 'Tab'时:- 获取容器内所有可聚焦元素:
modalEl.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') - 手动计算焦点位置,按 Shift+Tab / Tab 方向循环跳转到首/末项
- 调用
event.preventDefault()阻止默认行为
- 获取容器内所有可聚焦元素:
- 正确做法是:弹出层打开时,遍历背景中所有可聚焦元素,临时设
tabindex="-1"(注意不是移除,是覆盖);关闭时恢复原值(或移除该属性) - 更现代的方案是用
inert属性:document.body.inert = true,它会自动排除所有子元素的可聚焦性,无需逐个操作tabindex - 仅用
visibility: hidden或opacity: 0隐藏背景,却不处理tabindex或inert,键盘用户照样能 Tab 进去 - 解决方案:确保首次打开弹出层的操作由用户真实点击触发(而非 JS 自动调用),这样后续
.focus()才有效 - 若需程序化打开(如 URL 参数触发),可在
click事件监听器中先event.preventDefault()再setTimeout(() => modalEl.focus(), 0),绕过 Safari 的手势检测时机 - 不要依赖
autofocus属性,它在弹出层中基本无效,且不被所有浏览器支持
隐藏背景内容时 tabindex 的误用陷阱
很多人以为给背景加 tabindex="-1" 就能“禁用焦点”,这是错的——tabindex="-1" 不影响元素是否出现在 Tab 流中,只影响它能否被 .focus() 主动聚焦;已有的可聚焦元素(比如 button)依然会被 Tab 到。
移动端 Safari 的 focus() 失败问题
在 iOS Safari 中,未发生过用户手势(如 click/touchstart)前,对非原生可聚焦元素(比如 div[tabindex="-1"])调用 .focus() 会静默失败——没有报错,但焦点没生效。
真正难的不是写对那行 tabindex="-1",而是理解:焦点锁定本质是运行时状态管理,tabindex 只是其中一环;漏掉键盘拦截、忽略移动端限制、或误以为设个负值就能“屏蔽背景”,都会导致键盘导航在弹出层场景下彻底失效。











