如何用原生方案实现模态菜单的 Tab 键焦点循环(无障碍焦点陷阱)

作者:袖梨 2026-07-14

本文详解如何通过 HTML 原生语义与现代 API(<dialog>、inert、aria-modal)构建符合 WCAG 2.1 A 级标准的模态菜单焦点循环,无需手动监听 keydown 实现“Tab 到末尾自动跳回首项”,避免常见 JS 焦点陷阱误用。

本文详解如何通过 html 原生语义与现代 api(``、`inert`、`aria-modal`)构建符合 wcag 2.1 a 级标准的模态菜单焦点循环,无需手动监听 keydown 实现“tab 到末尾自动跳回首项”,避免常见 js 焦点陷阱误用。

在构建响应式汉堡菜单(hamburger drawer)等模态导航组件时,一个关键无障碍需求是:当键盘用户按 Tab 键遍历菜单项到达最后一项后,焦点应自然循环回第一项——而非逃逸至页面其他区域,导致视障用户“迷失上下文”。许多开发者误以为必须用 JavaScript 监听 keydown 事件并手动 focus() 跳转,实则这是对模态语义理解的偏差:真正的焦点循环,本质是浏览器在正确模态上下文中自动提供的行为,而非需手写的逻辑。

✅ 正确做法:用语义化模态容器接管焦点流

浏览器原生支持焦点循环的前提,是组件被明确标识为模态(modal)上下文。这可通过以下任一方式实现:

方案一:优先使用 <dialog> 元素(推荐 · 现代标准)

<button id="toggleDrawer">☰ 菜单</button><dialog id="drawerMenu">  <h2>导航菜单</h2>  <ul>    <li><a href="/home">首页</a></li>    <li><a href="/about">关于</a></li>    <li><a href="/contact">联系</a></li>  </ul>  <button id="closeDrawer" aria-label="关闭菜单">✕</button></dialog>
const drawer = document.getElementById('drawerMenu');const toggleBtn = document.getElementById('toggleDrawer');const closeBtn = document.getElementById('closeDrawer');toggleBtn.addEventListener('click', () => drawer.showModal()); // 自动捕获焦点、禁用背景、支持 Esc/backdrop 关闭closeBtn.addEventListener('click', () => drawer.close());

优势

  • showModal() 自动启用焦点陷阱(focus trap),Tab 键在菜单内自然循环;
  • 自动设置 aria-modal="true"、管理 aria-hidden、恢复触发元素焦点;
  • 无需手动 tabindex 或事件监听——浏览器原生保障 WCAG 合规性。

方案二:手动模态结构 + inert 属性(兼容旧版 & 非 <dialog> 场景)

若因样式或兼容性需自定义结构(如 div 实现 drawer),请严格遵循模态语义:

<!-- 触发按钮 --><button id="openDrawer">☰ 打开菜单</button><!-- 模态层结构 --><div id="drawerOverlay" class="overlay" hidden>  <nav id="drawerNav" role="dialog" aria-labelledby="drawerTitle">    <h2 id="drawerTitle">主菜单</h2>    <ul>      <li><a href="/products">产品</a></li>      <li><a href="/pricing">价格</a></li>      <li><a href="/support">支持</a></li>    </ul>    <button id="closeDrawerManual">关闭</button>  </nav></div><!-- 主内容区(模态激活时设 inert) --><main id="mainContent">  <h1>网站首页</h1>  <p>……</p></main>
const overlay = document.getElementById('drawerOverlay');const mainContent = document.getElementById('mainContent');const openBtn = document.getElementById('openDrawer');const closeBtn = document.getElementById('closeDrawerManual');openBtn.addEventListener('click', () => {  overlay.hidden = false;  mainContent.inert = true; // ✅ 关键:禁用主内容交互与可访问性暴露  // 浏览器会自动将焦点移入 nav 内首个可聚焦元素(如第一个 <a>)});closeBtn.addEventListener('click', () => {  overlay.hidden = true;  mainContent.inert = false;  openBtn.focus(); // ✅ 关闭后恢复焦点至触发按钮});

⚠️ 注意:inert 是现代标准(Chrome 105+、Firefox 119+、Safari 16.4+),旧浏览器需 polyfill(如 wicg-inert)。若无法使用 inert,则必须手动添加 aria-hidden="true" 并移除所有 tabindex,但 inert 更可靠、更语义化。

❌ 常见错误与风险警示

  • 不要仅靠 CSS visibility: hidden 或 display: none 控制可访问性:这些仅影响视觉,屏幕阅读器仍可读取、键盘仍可聚焦。
  • 避免手动实现“Tab 循环”逻辑:如监听 keyup 判断焦点位置再 focus() —— 这易引发“抵达即跳转”的 UX 毛病(用户刚 Tab 到最后一项就强制跳走),且违背浏览器原生焦点管理机制。
  • 非模态场景禁止焦点循环:若菜单设计为非阻断式(用户可同时操作菜单与页面),则绝不能限制 Tab 流——否则等于创建无法退出的焦点陷阱,违反 WCAG 2.1 2.4.3(Focus Order)与 2.1.2(No Keyboard Trap)。

总结:无障碍焦点循环 = 正确的模态语义 × 浏览器原生能力

要素 正确实践 错误实践
容器语义 <dialog> 或 role="dialog" + aria-labelledby 仅用 div 无任何 ARIA 标注
背景禁用 showModal() 或 inert 仅靠 opacity: 0.2 或 z-index 遮挡
焦点管理 依赖浏览器自动捕获/恢复 手动 focus() + keydown 监听
关闭后焦点 显式调用 triggerButton.focus() 忽略,焦点停留在 body 或丢失

截至 2026 年,主流浏览器已全面支持 <dialog> 与 inert,建议新项目默认采用 <dialog> 实现模态菜单;存量项目可渐进升级,用 inert 替代手工 aria-hidden + tabindex 控制。记住:无障碍不是“附加功能”,而是通过正确语义让浏览器替你完成合规性保障。

相关文章

精彩推荐