自定义下拉菜单必须用 tabindex="0" 使容器可聚焦并进入自然 Tab 流,配合 aria-haspopup="listbox"、aria-expanded 同步状态、role="listbox"/"option" 语义化结构及方向键/Enter/Esc 完整键盘导航。

focusable 元素必须用 tabindex="0" 而不是 -1
自定义下拉菜单常被包裹在 div 或 span 里,这些元素默认不可聚焦,键盘用户按 Tab 键会直接跳过。很多人误用 tabindex="-1"(仅支持 JS 主动聚焦),但无法进入自然 Tab 流——这意味着键盘用户根本进不去菜单。
正确做法是让触发按钮和菜单容器都具备可聚焦能力:
- 触发按钮(如
<button>)本身已是tabindex="0",无需额外设置 - 下拉菜单的根容器(如
<div class="dropdown-menu">)需显式加tabindex="0",且仅在展开时存在 DOM 中(避免隐藏时仍占 Tab 位置) - 菜单内每个可选项必须是
<button>或带role="menuitem"+tabindex="-1"的元素,不能只靠div+ click 事件
用 aria-expanded 和 aria-haspopup 告诉屏幕阅读器状态
光有视觉展开/收起不够,屏幕阅读器需要语义化提示。缺少这两个属性,NVDA 或 VoiceOver 用户可能完全不知道这是个可交互菜单。
关键点:
立即学习“前端免费学习笔记(深入)”;
- 触发按钮始终要有
aria-haspopup="listbox"(推荐)或"menu";listbox更贴合单选下拉场景 - 按钮的
aria-expanded值必须与菜单实际可见状态严格同步:菜单显示时为"true",隐藏时为"false" - 不要依赖 CSS
display: none或visibility: hidden来控制可访问性——这些不改变aria-expanded,会导致语义与实际脱节
键盘导航必须支持方向键 + Enter/Space + Esc
仅靠 Tab 键无法完成下拉菜单的完整操作流:Tab 进入触发按钮 → 按 Down Arrow 展开并聚焦第一个选项 → 方向键切换 → Enter 确认 → Esc 关闭。缺任何一环,键盘用户就得反复 Tab 绕行。
实操要点:
- 菜单展开后,自动将焦点移到第一个可选项(用
element.focus()),而不是停留在触发按钮上 - 监听
ArrowDown/ArrowUp:在选项间循环移动焦点,到达边界时停止或绕回(根据 WCAG 建议,推荐停止) -
Enter和Space都应触发选中(部分屏幕阅读器依赖 Space);Esc必须关闭菜单并把焦点移回触发按钮 - 避免在选项上监听
click代替键盘事件——这会让键盘用户被迫用鼠标模拟点击
菜单容器需用 role="listbox",选项用 role="option"
自定义下拉若用 role="menu" + menuitem,会被屏幕阅读器当作应用级菜单(如文件→打开),语义过重且不匹配表单控件场景。表单下拉本质是值选择,应走 listbox 模式。
结构示例(精简):
<button aria-haspopup="listbox" aria-expanded="false" aria-controls="my-listbox">选择颜色</button> <div id="my-listbox" role="listbox" tabindex="0"> <button role="option" aria-selected="false">红色</button> <button role="option" aria-selected="true">蓝色</button> <button role="option" aria-selected="false">绿色</button> </div>
注意:aria-selected 必须随用户操作实时更新,且只用于 role="option" 元素;aria-controls 指向菜单 ID,建立按钮与菜单的关联。
最易被忽略的是焦点管理细节:菜单收起时,如果没把焦点主动移回触发按钮,键盘焦点会丢失到页面顶部或下一个可聚焦元素,用户得重新 Tab 寻找入口。这比样式错位更伤体验。



















