原生表单控件无需设tabindex;仅非原生模拟控件需tabindex="0"并配role、keydown处理和:focus-visible;tabindex="-1"用于js主动聚焦;safari中dialog需手动实现焦点围捕。

原生表单控件(<input>、<button></button>、<select></select>、<textarea></textarea>)默认就参与 Tab 焦点流,根本不需要设 tabindex;强行加 tabindex="1" 或 tabindex="0" 不仅多余,还可能在 React/Vue 中触发 hydration 警告,或让 Safari 对同一按钮聚焦两次。
哪些表单元素绝对不要加 tabindex
以下元素天然具备 tabindex="0" 的行为,显式设置属于冗余操作:
-
<button></button>(无论是否带type属性) -
<a href></a>(有 href 才可聚焦) -
<input>(含type="text"、"email"、"checkbox"等) -
<select></select>和<textarea></textarea>
常见错误:给所有 <input> 统一加 tabindex="1",结果整个表单焦点顺序断裂——浏览器把所有正数元素提前排,原生控件被挤到最后;用户按一次 Tab 就跳到页脚,再按又回到顶部。
什么时候必须用 tabindex="0"
只有一种情况需要主动加 tabindex="0":你用非原生元素模拟了交互控件,比如 <div role="button"> 或 <code><span role="checkbox"></span>。这时 tabindex="0" 是让它“能被 Tab 到”的最小必要操作。
但加了只是第一步,还必须同步做三件事:
- 加
role属性(如role="button"),否则屏幕阅读器读不出语义 - 监听
keydown,对event.key === 'Enter'和event.key === ' '(注意是空格字符)做相同处理,并调用event.preventDefault()防止空格滚动页面 - 提供
:focus-visible样式,否则键盘用户看不见焦点在哪
tabindex="-1" 在表单里的真实用途
tabindex="-1" 不是“隐藏”或“禁用”,而是为 JS 主动聚焦预留入口。典型场景:
- 表单验证失败后,自动聚焦第一个出错的
<input>—— 它本身无需设值,但若你想聚焦一个<div> 错误提示区域,就得先给它 <code>tabindex="-1",再调.focus() - 模态框里的确认按钮是
<div> 实现的,必须带 <code>tabindex="-1",否则.focus()在 Safari 中静默失败 - 动态插入的帮助按钮(如城市字段旁的
<button>?</button>),不希望它进入主 Tab 流,但需在用户离开城市输入框时由 JS 主动聚焦,那就设tabindex="-1",并在blur事件里调.focus() - 打开后立即用
requestAnimationFrame(() => firstFocusable.focus())聚焦首个可聚焦元素(不能等 CSS 动画结束) - 监听
keydown拦截Tab和Shift+Tab,在对话框内手动循环焦点(别依赖:focusable,浏览器不支持该伪类) - 关闭前缓存触发源(如
open-btn),关闭后检查它是否仍存在且可聚焦,再调.focus()
注意:tabindex="-1" 对 display: none 或未挂载的 DOM 节点无效;移动端 Safari 在无用户手势前,对非原生元素调 .focus() 也会静默忽略。
Safari 中 dialog 和焦点管理的硬伤
在 Safari(全平台)中,<dialog>.showModal()</dialog> 后 Tab 键仍会逃逸到页脚、导航栏甚至 ,aria-modal="true" 完全失效。这不是体验问题,是焦点围捕机制缺失。
必须手动补三件事:
嵌套 <dialog></dialog> 更要分层管理可聚焦元素列表,子层打开前暂停父层的 trapTabKey 监听器,关闭后还原父层上次离开时的焦点位置——不是简单调 parentDialog.focus()。











