原生表单元素(如input、select、textarea、button、带href的a)绝对不能加tabindex="0",否则会干扰默认可访问性行为;必须加tabindex="-1"并手动focus()的仅限动态插入且非原生可聚焦的容器。

原生表单元素(<input>、<select></select>、<textarea></textarea>、<button></button>)默认就进 Tab 流,加 tabindex="0" 不但没用,还可能在 SSR/hydration 时让焦点错位或丢失。
哪些表单元素绝对不能加 tabindex="0"
给语义化控件手动加 tabindex="0" 是典型反模式。这些元素本就能被 Tab 到,加了反而干扰浏览器默认行为:
-
<input type="text">、<input type="email">、<input type="password">等所有input类型 -
<select></select>和<textarea></textarea> -
<button></button>(含type="submit"/type="reset") -
<a href></a>(只要带href)
它们的 name 和 type 属性已决定可访问性角色、软键盘行为、校验逻辑和屏幕阅读器播报方式——改用 tabindex 替代,等于主动放弃这些能力。
什么时候必须加 tabindex="-1" 并手动 focus()
动态插入的表单控件(如模态框里的第一个 <input>),浏览器不会自动聚焦。你得靠 JS 主动调 .focus(),但前提是目标元素得提前设 tabindex="-1":
- 只对非原生可聚焦容器(比如
<div role="dialog"> 内的标题栏)设 <code>tabindex="-1";原生<input>不需要它 - 模态框打开后立即执行:
document.querySelector('.modal input').focus(),该input必须已在 HTML 中存在且未被display: none或visibility: hidden隐藏 - 关闭模态框后,焦点应回退到触发它的按钮(可用
document.activeElement记录,或传参保存) -
focus({ preventScroll: true })在 Safari 中不支持,需先检测:if ('preventScroll' in FocusOptions.prototype) - 必须用
for和id显式配对:<label for="email">邮箱</label>+<input id="email" name="email"> - 嵌套写法也合法:
<label>邮箱<input name="email"></label>,但不利于 CSS 定位和部分辅助技术 - 不要用
aria-label替代<label></label>:前者只读不关联,无法触发点击聚焦,也无法参与表单提交逻辑 - 一组复选框/单选按钮必须用
<fieldset></fieldset>+<legend></legend>包裹,<legend></legend>是纯文本,会被朗读两次(进入和离开时) - 监听
keydown(不是keyup),第一时间调event.preventDefault()阻止页面滚动 - 用
event.key判断按键:"Enter"、" "(空格字符)、"ArrowDown"、"Home"、"End" -
Enter在keydown触发,Space在keyup触发,两个都得监听 - 方向键应跳转到对应选项,
Home/End直接到首项/末项,别让用户连按多次 - Tab 离开组件时,用
focusin+container.contains(document.activeElement)检测并循环回内部,而不是简单event.preventDefault()
label 和 for/id 关联是键盘可访问性的底线
没有正确关联的 <label></label>,键盘用户 Tab 到 <input> 时,屏幕阅读器根本不知道这是什么字段:
方向键和 Enter/Space 的响应必须手写
Tab 键负责顺序移动,Enter/Space 和方向键才是实际操作的关键。原生表单控件已内置这些行为,但自定义组合(如带下拉的 <input>)必须自己补全:
最常被忽略的是:tabindex="1" 这类正整数必须立刻删除——它不保证“第一个被 Tab 到”,只会把元素塞进所有正数里排序,破坏 DOM 语义顺序,且不可维护。真正的焦点控制靠结构、tabindex="0"(仅限非原生元素)、tabindex="-1"(仅用于 JS 聚焦)和 JS 精准接管。











