
本文详解如何通过 HTML 原生语义与现代 API(<dialog>、inert、aria-modal)构建符合 WCAG 2.1 A 级标准的模态菜单焦点循环,无需手动监听 keydown 实现“Tab 到末尾自动跳回首项”,避免常见 JS 焦点陷阱误用。
本文详解如何通过 html 原生语义与现代 api(`
在构建响应式汉堡菜单(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 控制。记住:无障碍不是“附加功能”,而是通过正确语义让浏览器替你完成合规性保障。

















