focusable元素必须显式声明tabindex才能进tab流;原生控件(button、a、input)默认已在tab流中,加tabindex="0"多余且易引发ssr/hydration焦点错位;仅需为div/span等模拟交互元素添加tabindex="0"并同步配role、keydown监听和:focus-visible样式;tabindex="-1"专用于js主动聚焦,不可被tab键到达。

focusable 元素必须显式声明 tabindex 才能进 Tab 流
原生可聚焦元素(<button></button>、<a></a>、<input>)默认就在 Tab 流里,加 tabindex="0" 不但多余,还可能在 SSR/hydration 时引发焦点错位。真正需要加的,是那些用 <div> 或 <code><span></span> 模拟交互行为的元素。
常见错误是只写 tabindex="0" 却漏掉语义支撑:
- 必须配
role属性,比如<div role="button" tabindex="0">,否则屏幕阅读器读不出它是按钮<li>必须监听 <code>keydown,对Enter和Space都响应——Space在keyup触发,Enter在keydown触发,只监听一种会丢操作 - 必须提供
:focus-visible样式,全局* { outline: none }会直接干掉它,得单独重置 - 别给
<input>或<button></button>加tabindex="-1":它们本就能被 Tab 到,加了等于主动踢出流 -
focus({ preventScroll: true })在 Safari 不支持,传对象参数会静默失败,得先检测:if ('preventScroll' in FocusOptions.prototype) - 元素若被
display: none或visibility: hidden隐藏,focus()会失败,哪怕 tabindex 是 -1 - 在
keyup里处理方向键:此时浏览器可能已滚动页面,你的逻辑被覆盖或延迟 - 没调
event.preventDefault():尤其在容器内,方向键默认会触发页面滚动 - Home/End 键没特殊处理:只依赖 Tab 循环不够,得手动跳转到首项/末项
- 每次方向键都现场查 DOM:性能差,应缓存当前焦点索引,只更新状态
- 监听根容器的
focusin,而不是子项的blur:后者时机不可靠,且嵌套组件间易冲突 - 用
data-focus-lock类或上下文标记区分层级:外层组件不该拦截内层合法的焦点转移 - 首次聚焦时,别依赖
tabindex="0"自动进流:得手动focus()到合理起点(如首项或上次活跃项) - 滚动容器(如卡片列表)本身要是
tabindex="0",否则焦点走到末尾就“消失”,实际是容器没资格进 Tab 流
tabindex="-1" 是动态聚焦的唯一安全入口
模态框打开后自动聚焦到第一个输入框、下拉菜单展开后聚焦到首项——这些都不是靠 DOM 顺序“等”来的,而是 JS 主动调用 element.focus() 实现的。但前提是那个目标元素得先设 tabindex="-1"。
注意几个硬约束:
方向键导航必须用 keydown + event.key
监听 ArrowDown 这类方向键,必须用 keydown,且优先判断 event.key 而非 event.code。因为 event.code 反映物理按键位置(比如某些键盘上 "Down"),而 event.key 才是逻辑意图(统一为 "ArrowDown")。
容易踩的坑:
焦点不能“掉出”组件边界
用户按 Tab 离开下拉菜单或树形控件时,焦点不该跳到页面其他地方,而应循环回组件内部——这不是禁用 Tab,而是用 focusin + container.contains(document.activeElement) 主动拦截并重定向。
关键细节:
最常被忽略的不是代码怎么写,而是测试方式:必须从新标签页打开、全程禁用鼠标、只用 Tab/Shift+Tab/Enter/Space 走一遍。DevTools 里看 Focusable: true 和 Focused: true 才算真实可达,视觉 outline 框≠真正获得焦点。











