tabindex="-1"仅使元素可被javascript主动聚焦,不拦截tab键流;首次聚焦需手动调用.focus()到可交互元素,tab溢出须keydown拦截重定向,关闭后须回退至有效节点。

tabindex="-1" 不是拦截开关,而是聚焦入口预备位
给元素设 tabindex="-1" 本身完全不拦截任何焦点流程——它只做一件事:让该元素可被 .focus() 主动调用,但排除在 Tab 键自然流之外。很多人误以为加了这个属性就能“锁住焦点”,结果弹窗打开后键盘用户依然从页面顶部开始 Tab,根本进不到弹层里。
常见错误包括:
- 只给弹层根容器加
tabindex="-1",却不调用modalEl.focus(),导致焦点毫无变化 - 把
tabindex="-1"加在纯文本<h2></h2>或<div> 上,然后试图 <code>.focus()它——这类元素默认不可聚焦,即使有tabindex="-1",.focus()也会静默失败(除非同时加role="heading"等语义) - 在移动端 Safari 中,未在用户手势(如
click)回调内立即调用.focus(),而是丢进setTimeout或异步 Promise,导致聚焦失效 - 原生可聚焦元素(
<button></button>、<input>、<a href></a>)默认就支持.focus(),无需额外加tabindex="-1";加了反而会把它踢出 Tab 流,造成多一次无效停靠 - 自定义控件(如
<div role="button">)必须同时满足:<code>tabindex="-1"+role="button"+keydown监听(响应 Enter/Space) - React/Vue 中,确保聚焦逻辑在组件挂载完成之后执行(如
useEffect(() => { el.focus() }, [])),避免 ref 还未绑定就调用 - 若元素被
display: none或父级设了inert,.focus()会静默失败,需检查渲染状态和祖先节点 - 监听范围必须是弹层容器(
modalEl),不是 document 全局 - 获取有效可聚焦项用:
modalEl.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),注意排除tabindex="-1"的非交互元素 - 判断方向:用
event.shiftKey区分 Shift+Tab 和 Tab;用document.activeElement判断当前焦点是否在首/末项 - 必须调
e.preventDefault(),否则浏览器默认行为会先把焦点移出边界,再由你拉回来——这会造成一次闪烁式逃逸 - 最稳妥做法:打开前记录
const prevActive = document.activeElement,关闭后直接prevActive.focus() - 若触发源是动态生成的(如某行“编辑”按钮),需在点击时保存其唯一标识(如
data-id),关闭后通过 selector 查找并聚焦 - 若触发元素已被移除(如整行删除),应回退到最近的逻辑父容器(如
<table> 或 <code><ul></ul>),而不是降级到document.body - 别依赖
blur事件做回退——它可能被多次触发,且时机不可控
焦点锁定真正的复杂点不在
首次聚焦必须落在可交互元素上,且时机要准
模态框打开时,焦点应落在第一个真正可操作的控件上,比如确认按钮或输入框。这不是靠 DOM 顺序自动发生的,必须手动触发。
Tab 键溢出靠拦截重定向,不是靠 tabindex 控制
仅靠 tabindex="-1" 无法阻止 Tab 键跳出弹层。真正起作用的是监听 keydown,捕获 Tab,再手动控制焦点流向。
关闭后焦点回退容易被忽略,但影响极大
弹层关闭后,焦点不能消失、不能跳到 body、更不能卡在已销毁的 DOM 节点上。键盘用户会立刻丢失上下文,尤其在嵌套列表或动态表格中极易卡死。
tabindex 值本身,而在于三件事必须严格同步:首次聚焦的元素必须可交互且时机正确、Tab 拦截必须在焦点将逃逸的瞬间干预、关闭后的回退必须指向一个真实存在且语义合理的节点。漏掉任一环,对键盘用户的体验就是断崖式下降。











