语义化多级导航应使用嵌套 ul/li 结构,外层 ul 表示一级导航,子菜单 ul 必须嵌套在父 li 内;需添加 aria-label、role="menu"、aria-haspopup 等 ARIA 属性,避免 display: none 隐藏子菜单,优先采用视觉隐藏但保留可聚焦性方案,并确保键盘导航闭环。

用 ul 嵌套实现语义化多级导航
HTML 多级导航本质是嵌套的列表结构,不是靠 div + CSS 位置硬堆出来的。浏览器和读屏器依赖 ul/li 的层级关系理解导航深度,手写 div 嵌套会丢失语义、影响可访问性。
正确做法是每一层子菜单都用一个独立的 ul 包在父 li 内部:
<nav aria-label="主导航">
<ul>
<li><a href="/home">首页</a></li>
<li>
<a href="/products">产品</a>
<ul>
<li><a href="/products/web">Web 应用</a></li>
<li><a href="/products/mobile">移动应用</a></li>
</ul>
</li>
</ul>
</nav>
- 外层
ul表示一级导航,每个li是一个顶级项 - 子菜单必须直接放在父
li内部,不能提出来放到底层ul外 -
aria-label是必须加的,否则屏幕阅读器无法识别该区域用途 - 不要用
ol,导航顺序不具编号意义,且部分辅助技术对ol的嵌套支持不稳定
CSS 控制显示/隐藏时避免用 display: none 直接删子菜单
很多开发者一上来就给子 ul 加 display: none,鼠标悬停再 display: block —— 这会导致键盘用户无法用 Tab 键进入子项,也破坏焦点流。
更稳妥的做法是用视觉隐藏但保留可聚焦性:
立即学习“前端免费学习笔记(深入)”;
- 用
position: absolute+clip-path: inset(100%)或opacity: 0+pointer-events: none隐藏子菜单 - 悬停或聚焦父
a时,改回opacity: 1+pointer-events: auto - 必须为子菜单添加
role="menu",每个子a加role="menuitem",并用aria-haspopup="true"和aria-expanded="false"动态控制状态 - JavaScript 需监听
focusin事件(而非仅mouseover),确保键盘用户能展开
超过三级的树状结构要限制展开深度
纯 CSS 实现四级以上导航容易失控:子菜单方向错乱、定位偏移、z-index 层叠打架。真实项目中,三级已接近体验上限;四级开始建议降级处理。
- 第四级及更深的菜单,统一收进「更多」二级项里,用模态框或侧滑面板承载,避免横向溢出视口
- 如果必须保留在原结构中,改用 JavaScript 管理定位逻辑(例如监听
window.resize重算 left/top) - 移动端优先项目,直接放弃多级下拉,改用「点击展开」+ 全屏抽屉式菜单,CSS 不再依赖
:hover - 所有深层菜单项的
tabindex必须动态管理:未展开时设为-1,展开后设为0
服务端生成树状 HTML 时注意 id 和 aria-owns 的唯一性
动态渲染(如 Vue/React 或模板引擎)生成多级导航时,容易忽略 ID 冲突和 ARIA 关联失效问题。
- 每个子菜单
ul必须有唯一id,格式建议:submenu-products-1,避免静态写死如submenu-1 - 父
a标签需用aria-controls="submenu-products-1"明确绑定,不能只靠 DOM 位置推测 - 如果使用 React,确保
key基于完整路径生成(例如"nav-products-web"),防止列表重排后 ARIA 关系错位 - Node.js 模板(如 EJS/Nunjucks)中,递归宏调用时传入当前层级 depth 参数,用于生成不同 class 名(如
nav-level-2)和过渡动画延迟



















