学完这篇,你能让站点的所有交互都能纯键盘走通——Tab 顺序合理、焦点看得见、弹窗不"漏焦点"、关闭后焦点能回到原处。
第一步:能用原生元素就别用 div
浏览器的键盘支持是免费送的:<button> 天然可 Tab 聚焦、可回车/空格触发;<a href> 天然可聚焦可回车跳转;<input>、<select>、<textarea> 同理。你手写一个 <div class="btn" onclick="...">,这些能力全部归零。
<!-- 别这样 -->
<div class="btn" onclick="submit()">提交</div>
<!-- 这样 -->
<button type="button" onclick="submit()">提交</button>
原生 <button> 还自带禁用态、type 语义和屏幕阅读器播报。样式不一样就写 CSS 覆盖(button { all: unset; } 起手),别为了外观换掉语义。
注意:<a> 没有 href 是不能聚焦的,写成 <a onclick> 等于自废键盘操作。
第二步:tabindex 只用 0 和 -1
tabindex="0":把原本不可聚焦的元素加入 Tab 顺序,位置由它在 DOM 里的位置决定。
tabindex="-1":可被 JS focus() 聚焦,但不进 Tab 顺序——弹窗容器、错误提示框用它。
tabindex="1" 及以上:不要用。它会把元素强行插到所有自然顺序元素之前,多个正数值还会互相打架,最终焦点顺序和视觉顺序完全脱节,维护期无人敢改。
焦点顺序的唯一可靠来源是 DOM 顺序。视觉上想调换位置,用 CSS(flex 的 order、grid 的 grid-area)就行,但要注意:CSS 调顺序时,视觉顺序和焦点顺序会不一致,这种情况要么改 DOM 结构,要么接受它——通常改 DOM 更省事。
第三步:焦点必须看得见
/* 不要无脑 outline: none */
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
:focus-visible 只在"应该是键盘操作"时出现,鼠标点击按钮不会冒出蓝色框,兼顾美观与可访问性。现代浏览器全支持。
注意:如果设计稿要求去掉默认轮廓,务必同时提供替代可视样式(描边、背景变化、下划线),否则键盘用户完全不知道自己在哪。这是最常见的可访问性事故。
第四步:加一个跳过导航链接
Tab 头几下的位置最宝贵。长导航站点的键盘用户会被迫按十几次 Tab 才到正文。开头放一个:
<a class="skip-link" href="#main">跳到主要内容</a>
<main id="main" tabindex="-1">...</main>
.skip-link {
position: absolute; left: -9999px;
}
.skip-link:focus {
left: 8px; top: 8px; z-index: 999;
background: #fff; padding: 8px 12px;
}
<main> 加 tabindex="-1" 才能接住 focus()。
第五步:弹窗做焦点陷阱,关闭后归还焦点
弹窗打开后,Tab 应该只在弹窗内循环,不能跑到背后的页面。原生 <dialog> 用 showModal() 打开时浏览器已帮你做了这层。手写弹窗时:
const dialog = document.querySelector('#dlg');
const lastFocused = document.activeElement;
dialog.querySelector('button').focus(); // 打开后移入焦点
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
if (e.key !== 'Tab') return;
const items = dialog.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = items[0], last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
});
function close() {
dialog.hidden = true;
lastFocused?.focus(); // 焦点回到触发按钮
}
背景内容遮挡时,给遮罩外的容器加 inert(现代浏览器支持),一次性禁掉整块子树的聚焦与点击,比手动给每个元素 tabindex="-1" 稳得多。
注意:关闭弹窗不还焦点,键盘用户会被丢回页面顶部重新 Tab 一遍,这是体验最差的一环,务必补上 lastFocused.focus()。
第六步:复合组件用"漫游 tabindex"
标签页、树、工具栏这类组件,内部十几个焦点项如果全在 Tab 顺序里,用户想跳过整个组件要按十几次。做法是:只有当前选中项 tabindex="0",其余 tabindex="-1",用方向键在内部移动焦点。
tabs.addEventListener('keydown', (e) => {
const list = [...tabs.querySelectorAll('[role="tab"]')];
const i = list.indexOf(document.activeElement);
if (e.key === 'ArrowRight') {
e.preventDefault();
list[(i + 1) % list.length].focus();
}
});
移动焦点后同步更新 tabindex 和 aria-selected(选中/激活状态跟焦点是两回事,自动激活型组件可以合并处理)。
第七步:用键盘自测一遍
不看鼠标,只按 Tab / Shift+Tab / Enter / Space / Esc / 方向键走完全站主流程:能不能登录、能不能发帖、能不能关掉每个弹窗、焦点是否始终可见、顺序是否和视觉一致。DevTools 的 Elements 面板选中元素会显示 tabindex 与 :focus 状态,也可以直接在 Console 里 document.activeElement 查当前焦点。
小结
- 优先用
<button>、<a href>、表单控件,语义免费带键盘能力
tabindex 只用 0 和 -1,永不用正数;焦点顺序靠 DOM 决定
- 焦点样式用
:focus-visible,绝不 outline: none 了事
- 页面顶部加跳过导航链接,
<main> 配 tabindex="-1"
- 弹窗:打开移入焦点 → Tab 循环 → Esc 关闭 → 焦点归还触发元素,背景加
inert
- 复合组件用漫游 tabindex + 方向键,整块只占一个 Tab 位
- 最后一定纯键盘走一遍,比读十篇规范有用