
导航菜单代码示例:制作支持下拉展开的菜单元素
下拉导航常用于把同一主题下的多个页面收在一个入口下面,例如“产品”下的不同功能页、“资源”下的文档与示例,或网站主导航中的分类链接。它能节省导航栏空间,但也增加了一层交互:用户必须知道入口可以展开,并且能用鼠标、键盘或触屏打开和访问其中的链接。
本文先用原生 HTML 的 <details> 和 <summary> 实现一个无需 JavaScript 的导航下拉菜单,再说明它的适用边界、常见错误和安全注意事项。示例面向普通网站导航,不把它伪装成桌面软件里的“菜单控件”。
先选对交互模型
网页导航的主要任务是带用户前往其他页面,因此下拉面板里的内容通常应该是普通链接。网站导航不等同于应用程序菜单:一般不需要给链接添加 role="menuitem",也不应仅为了“看起来像菜单”就使用一整套菜单键盘交互。错误套用 ARIA 菜单角色,反而可能让读屏软件按应用菜单的方式解释控件,导致预期与实际操作不一致。
如果下拉入口只是展开或收起一组导航链接,原生 <details> 是轻量选择:
<summary>提供可操作的展开入口,浏览器支持鼠标和键盘操作,并会暴露展开状态。<details>的open属性反映其展开状态。没有 JavaScript 时,用户仍可打开和关闭内容。- 面板内容可以直接使用列表和链接等语义化 HTML。
如果展开动作还要触发复杂逻辑、需要多个面板互斥、要与路由状态同步,或需要点击页面其他位置时自动关闭,则可以使用按钮和 JavaScript 实现。此时按钮应正确维护 aria-expanded,并通过 aria-controls 关联内容区域。本文先从需求较简单的原生方案开始。
可运行示例:原生 details 导航下拉
将下面代码保存为 .html 文件即可在现代浏览器中查看。示例包含站点名称、常规导航链接和一个可展开的分类。CSS 负责布局与外观,展开和收起由浏览器原生控件完成。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>下拉导航示例</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #1f2937;
}
.site-header {
background: #fff;
border-bottom: 1px solid #d1d5db;
}
.site-nav {
max-width: 960px;
min-height: 64px;
margin: 0 auto;
padding: 0 20px;
display: flex;
align-items: center;
gap: 28px;
}
.brand {
color: inherit;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
}
.nav-list {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-list a,
.nav-list summary {
display: block;
padding: 10px 12px;
border-radius: 6px;
color: inherit;
font: inherit;
text-decoration: none;
cursor: pointer;
}
.nav-list a:hover,
.nav-list summary:hover {
background: #f3f4f6;
}
.nav-list a:focus-visible,
.nav-list summary:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
.nav-dropdown {
position: relative;
}
.nav-dropdown summary {
list-style: none;
}
.nav-dropdown summary::-webkit-details-marker {
display: none;
}
.nav-dropdown summary::after {
content: "▾";
display: inline-block;
margin-left: 8px;
transition: transform 160ms ease;
}
.nav-dropdown[open] summary::after {
transform: rotate(180deg);
}
.dropdown-panel {
position: absolute;
z-index: 10;
top: calc(100% + 6px);
left: 0;
width: 220px;
margin: 0;
padding: 6px;
list-style: none;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
}
.dropdown-panel a {
padding: 10px;
}
@media (max-width: 600px) {
.site-nav {
align-items: flex-start;
flex-direction: column;
gap: 8px;
padding-top: 12px;
padding-bottom: 12px;
}
.nav-list {
align-items: flex-start;
flex-direction: column;
width: 100%;
}
.nav-dropdown {
width: 100%;
}
.dropdown-panel {
position: static;
width: 100%;
margin-top: 4px;
box-shadow: none;
}
}
@media (prefers-reduced-motion: reduce) {
.nav-dropdown summary::after {
transition: none;
}
}
</style>
</head>
<body>
<header class="site-header">
<nav class="site-nav" aria-label="主导航">
<a class="brand" href="/">示例站点</a>
<ul class="nav-list">
<li><a href="/about">关于我们</a></li>
<li>
<details class="nav-dropdown">
<summary>产品</summary>
<ul class="dropdown-panel">
<li><a href="/products/overview">产品概览</a></li>
<li><a href="/products/features">功能介绍</a></li>
<li><a href="/products/pricing">价格方案</a></li>
</ul>
</details>
</li>
<li><a href="/docs">文档</a></li>
<li><a href="/contact">联系我们</a></li>
</ul>
</nav>
</header>
</body>
</html>
示例的结构与样式如何工作
用列表表达导航关系
<nav> 表示导航区域,aria-label="主导航" 为该区域提供名称。页面如果还有页脚导航、目录导航等多个 <nav>,应使用不同且清楚的名称,帮助辅助技术用户区分它们。导航项放在 <ul> 与 <li> 中,层级关系由真实 HTML 结构表达,而不是只靠缩进或颜色暗示。
“关于我们”“文档”等直接跳转的项目是链接;“产品”本身在这个示例中是展开入口,因此用 <summary>,并把链接放入其下方的列表。不要把一个普通的 <div> 当作按钮使用:它默认没有合适的键盘行为和控件语义。
details 的展开状态由浏览器维护
当用户激活 <summary>,浏览器会切换所属 <details> 的展开状态。默认情况下,面板关闭;如果希望初次载入时展开,可以在元素上添加 open,例如 <details open>。该属性是布尔属性,写上就表示展开;写成 open="false" 仍然表示存在该属性,并不等于关闭。
示例没有给 <summary> 手动添加 role="button" 或 aria-expanded,因为它已经处在原生 disclosure 控件中。重复添加不必要的 ARIA 属性,会增加状态同步负担,甚至造成标注状态与实际展开状态不一致。使用原生元素时,优先依赖其内建语义。
面板定位与响应式布局
桌面样式将 .nav-dropdown 设置为相对定位,再将面板绝对定位到入口下方。面板脱离常规文档流,因此不会推开导航栏里的其他项目。z-index 让面板尽量显示在相邻内容上方,但它不能突破所有层叠上下文:祖先元素若建立了新的层叠上下文,或周围容器设置了裁切,仍可能遮住面板,需要结合实际页面结构排查。
在窄屏断点下,示例把导航改为纵向排列,并将面板恢复为普通文档流中的内容。这样面板不会因为屏幕狭窄而溢出视口,也更适合触屏浏览。断点值只是示例选择,不是标准要求;应根据实际导航长度、字体缩放和页面布局测试,而不是照搬一个固定的“手机宽度”。
适用场景与边界
适合使用下拉展开的情况
- 主导航项目较多,需要按主题归类,但分类下的链接数量仍可快速浏览。
- “产品”“服务”“资源”等入口下有一组相关页面,用户能从入口文字推断其内容。
- 展开内容主要是站内导航链接,而不是一组需要执行复杂操作的命令。
- 桌面与触屏布局都经过设计,且用户无需依赖悬停才能找到子项。
不适合或需要另行设计的情况
如果所有链接都很重要,且页面空间允许,直接展示导航通常比隐藏内容更容易发现。子项数量很多时,应考虑分类页、侧边栏或专门的导航页面;把大量链接塞进小面板,会增加查找成本。若入口同时承担“跳转到分类首页”和“展开子菜单”两种职责,不要让同一个点击动作含糊不清。可以保留一个明确的分类链接,并用相邻的独立按钮负责展开,或者在分类首页与子链接之间重新组织信息架构。
原生 <details> 不会自动实现“点击菜单外部就关闭”,也不保证多个菜单互斥。它可以通过鼠标和键盘操作,但具体外观和部分细节受浏览器影响。若产品需求明确要求点击外部关闭、同一时间只允许一个面板展开、打开后自动聚焦到某个元素,或根据路由变化自动收起,就需要额外设计与测试,不能把这些行为当作 <details> 自带功能。
键盘、触屏与可访问性检查
- 确认入口可聚焦。使用 Tab 键应能到达“产品”,按 Enter 或空格应能切换展开状态。使用原生
<summary>时,浏览器提供了相应键盘支持。 - 确认子链接可访问。展开后继续按 Tab,焦点应能到达面板中的各个链接,且焦点顺序符合页面阅读顺序。
- 不要只用颜色表示交互状态。示例提供了明显的
:focus-visible轮廓。不要移除轮廓后不给键盘用户替代提示。 - 不要只支持悬停。纯 CSS 的
:hover展开在触屏上不可靠,也不一定能满足键盘操作。悬停效果可以作为补充,不能成为唯一入口。 - 提供清楚的入口文案。“更多”可能含义不明;“产品”或“产品分类”能更准确地提示内容。若使用图标作为入口,也要确保控件有可访问名称。
- 尊重减少动态效果设置。示例用
prefers-reduced-motion减少箭头过渡效果。交互不应依赖动画才能表达状态。
可访问性并非只靠添加 ARIA 属性完成。实际应在常用浏览器与辅助技术组合中检查导航区域的名称、控件展开状态、链接顺序和焦点可见性。尤其要避免自行添加与原生语义冲突的角色或状态。
常见错误及修正方向
错误一:鼠标移入才显示子菜单
只用 :hover 显示面板,容易导致键盘用户无法打开,触屏用户也可能遇到不稳定行为。应提供可激活的语义控件,例如 <summary> 或真正的 <button>。如果设计同时支持悬停快捷展示,也应保证点击、键盘和触屏仍能完成同一任务。
错误二:给链接套上菜单角色
站点导航里的链接通常仍然是普通链接,不应为了视觉效果加上 role="menu"、role="menuitem"。ARIA 角色可能改变辅助技术对控件的解释,却不会自动实现方向键导航、焦点管理等行为。角色一旦采用,作者就有责任实现对应交互模型。一般网站导航应优先保留原生的 nav、列表和链接语义。
错误三:用空链接或无意义的 href
将展开入口写成 <a href="#">,常会造成点击跳到页面顶部、地址栏片段变化或需要额外阻止默认行为。展开动作不是页面跳转时,应使用适合展开的控件;真正的导航目的地则使用有效链接。不要让链接只负责改变菜单显示状态。
错误四:隐藏面板后仍让内容参与操作
如果用自定义 CSS 或 JavaScript 控制面板可见性,不能只把内容移出屏幕或设为透明,却让其中链接继续进入 Tab 顺序或被辅助技术读取。隐藏与显示应使用合适的状态管理方式,确保视觉状态、键盘可达性和辅助技术可见性一致。使用原生 <details> 可以避免自行维护一部分隐藏状态逻辑。
错误五:面板被裁切或被其他内容遮挡
即使设置了较大的 z-index,面板也可能受祖先的 overflow: hidden、层叠上下文或其他定位元素影响。排查时应检查面板的祖先容器、层叠上下文与页面布局,不要只不断增加 z-index 数值。窄屏下将面板放回文档流,往往比坚持悬浮定位更稳妥。
需要 JavaScript 时:状态要同步
当需求超出原生控件能力时,可以用按钮控制一个内容区域。下面是状态管理的核心示例,不是前面完整页面的替代代码。按钮的 aria-expanded 要与面板是否隐藏保持一致;点击按钮时,两者一起更新。
<button type="button"
aria-expanded="false"
aria-controls="products-panel"
id="products-toggle">
产品
</button>
<ul id="products-panel" hidden>
<li><a href="/products/overview">产品概览</a></li>
<li><a href="/products/features">功能介绍</a></li>
</ul>
<script>
const toggle = document.querySelector("#products-toggle");
const panel = document.querySelector("#products-panel");
toggle.addEventListener("click", () => {
const isExpanded = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", String(!isExpanded));
panel.hidden = isExpanded;
});
</script>
这段代码展示的是最小的开合逻辑。它没有实现点击外部关闭、按 Escape 关闭、焦点移动、多个菜单互斥或移动端导航折叠等行为。若产品要求这些功能,应逐项定义预期,再补齐实现和测试。尤其是自动移动焦点:普通导航展开后通常可以让焦点留在触发按钮上,让用户自然 Tab 到链接;不要无理由地抢夺焦点。
如果加入点击外部关闭逻辑,要避免在面板内点击链接时被误判为外部点击;如果加入 Escape 关闭,要明确关闭后焦点是否返回触发按钮。若面板随页面滚动、路由切换或导航状态变化,也要确保 ARIA 状态、隐藏状态和视觉状态不会分离。交互状态不能只更新其中一个表现层。
安全注意事项:链接地址和动态内容
固定写在 HTML 里的站内链接通常不涉及动态内容注入,但只要菜单项来自接口、用户输入、配置文件或第三方数据,就要把“显示文本”和“链接地址”分别按用途处理。
- 动态文本应作为文本插入,而不是未经处理地拼接成 HTML。客户端代码优先使用
textContent创建文本节点;不要把不可信字符串直接传给innerHTML。 - 动态链接地址需要按业务规则校验。若只允许本站链接,应限制为预期的站内路径;若允许外部 URL,应解析并检查协议和目标规则。不要因为字符串看起来像 URL 就认为它安全。
- 避免把不可信值放进事件处理属性、脚本片段或未经验证的 URL 属性。HTML 转义只能解决特定上下文中的字符解析问题,不等于通用安全校验。
- 新窗口打开外部链接时,应按实际需求设置
target="_blank",并使用适当的rel值,例如rel="noopener noreferrer"。是否使用noreferrer会影响来源信息传递,应结合产品需求决定。
HTML 转义、URL 校验和内容安全策略解决的是不同问题,不能互相替代。也不要把下拉菜单当作权限控制:隐藏一个链接不会阻止用户直接访问对应 URL。页面和接口的访问权限必须由服务端按用户身份与授权规则验证。
上线前的检查清单
- 下拉项是否确实能帮助用户理解和查找,而不是为了把重要入口藏起来?
- 菜单入口是否明确区分“展开”与“跳转”?链接是否都有有效目的地?
- 键盘用户能否打开面板、访问所有链接,并看清当前焦点?
- 窄屏、放大字体和较长文案下,面板是否仍可见、可操作且不溢出?
- 面板与相邻内容是否存在裁切、遮挡或层叠问题?
- 如果使用 JavaScript,按钮状态、面板可见性与 ARIA 状态是否始终一致?
- 如果菜单项是动态数据,文本和 URL 是否分别经过适合其上下文的处理?
对于多数简单的网站导航,原生 <details>、<summary>、列表和链接已经足够。先用原生语义满足核心需求,再根据真实交互要求增加脚本,比一开始就自制复杂菜单更容易维护,也更不容易遗漏键盘和辅助技术行为。




