
导航菜单代码示例:制作带下拉子菜单的网页导航
下拉导航适合把网站的主要栏目和相关子栏目组织在一起:例如企业站的“产品”与产品分类、文档站的“指南”与章节入口,或电商网站的商品类别。它能节省页面空间,但也容易引入键盘无法操作、触屏上难以展开、菜单被容器裁切等问题。
本文用原生 HTML 和 CSS 实现一个可直接运行的导航示例。菜单使用 <nav>、列表、链接以及原生的 <details> 和 <summary>。不依赖框架或 JavaScript,鼠标、触屏和键盘用户都能展开子菜单。示例适合作为普通网站导航的起点;如果要实现复杂的应用菜单、路由状态同步或精细的焦点管理,则需要进一步设计交互。
一、什么时候适合使用下拉子菜单
下拉菜单适用于栏目数量较多,但用户仍能根据清晰的类别名称找到目标的场景。顶层菜单应表达网站的主要分区,子菜单则补充该分区下的页面。例如“产品”是顶层入口,子菜单可以列出“产品概览”“产品 A”“产品 B”。
如果网站只有少量页面,把每个页面直接放在导航栏中通常更简单。如果用户必须逐层展开才能理解网站结构,或者菜单包含很多层级,下拉菜单反而会增加寻找成本。一般应尽量减少层级和子项数量,并考虑在栏目页提供完整的分类入口。
需要区分“导航”与“应用菜单”。网站导航用于跳转页面,适合用链接表达;应用菜单则可能包含按钮、勾选项、快捷键和复杂键盘行为。不要仅仅因为视觉上像菜单,就把网页导航实现成依赖方向键、角色属性和自定义焦点管理的 ARIA 菜单。语义和交互应与真实用途相匹配。
二、完整可运行示例
将下面代码保存为 index.html,用浏览器打开即可查看。示例中的链接指向演示地址,请替换成网站实际页面。为了简化演示,菜单没有加入品牌图片,也不需要外部依赖。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>带下拉子菜单的导航示例</title>
<style>
:root {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #172033;
background: #f5f7fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.site-header {
background: #fff;
border-bottom: 1px solid #dce2ec;
}
.site-nav {
max-width: 72rem;
margin: 0 auto;
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.brand {
color: #173b75;
font-size: 1.15rem;
font-weight: 750;
text-decoration: none;
white-space: nowrap;
}
.nav-list,
.submenu {
margin: 0;
padding: 0;
list-style: none;
}
.nav-list {
display: flex;
align-items: center;
gap: 0.35rem;
}
.nav-link,
.menu-summary {
display: inline-flex;
align-items: center;
min-height: 2.75rem;
padding: 0.55rem 0.8rem;
border: 0;
border-radius: 0.45rem;
background: transparent;
color: #26334a;
font: inherit;
text-decoration: none;
cursor: pointer;
}
.nav-link:hover,
.menu-summary:hover,
.nav-link[aria-current="page"] {
background: #edf3ff;
color: #164fa4;
}
.nav-link:focus-visible,
.menu-summary:focus-visible,
.brand:focus-visible {
outline: 3px solid #2874d0;
outline-offset: 3px;
}
.has-submenu {
position: relative;
}
.menu-summary {
list-style: none;
gap: 0.45rem;
}
.menu-summary::-webkit-details-marker {
display: none;
}
.menu-summary::after {
content: "";
width: 0.45rem;
height: 0.45rem;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: translateY(-0.12rem) rotate(45deg);
transition: transform 160ms ease;
}
.has-submenu[open] > .menu-summary::after {
transform: translateY(0.1rem) rotate(225deg);
}
.submenu {
position: absolute;
z-index: 10;
top: calc(100% + 0.2rem);
left: 0;
min-width: 13rem;
padding: 0.4rem;
border: 1px solid #dce2ec;
border-radius: 0.65rem;
background: #fff;
box-shadow: 0 0.7rem 1.8rem rgb(20 35 60 / 14%);
}
.submenu a {
display: block;
padding: 0.65rem 0.75rem;
border-radius: 0.4rem;
color: #26334a;
text-decoration: none;
}
.submenu a:hover,
.submenu a:focus-visible {
background: #edf3ff;
color: #164fa4;
}
main {
max-width: 72rem;
margin: 0 auto;
padding: 3rem 1.25rem;
}
main p {
max-width: 42rem;
line-height: 1.75;
}
@media (max-width: 42rem) {
.site-nav {
align-items: flex-start;
flex-direction: column;
gap: 0.5rem;
}
.nav-list {
width: 100%;
align-items: stretch;
flex-direction: column;
}
.nav-list > li {
width: 100%;
}
.nav-link,
.menu-summary {
width: 100%;
justify-content: space-between;
}
.submenu {
position: static;
min-width: 0;
margin: 0.15rem 0 0.4rem 0.5rem;
box-shadow: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<header class="site-header">
<nav class="site-nav" aria-label="主导航">
<a class="brand" href="/">示例网站</a>
<ul class="nav-list">
<li>
<a class="nav-link" href="/" aria-current="page">首页</a>
</li>
<li>
<details class="has-submenu">
<summary class="menu-summary">产品</summary>
<ul class="submenu">
<li><a href="/products/">产品概览</a></li>
<li><a href="/products/alpha">产品 A</a></li>
<li><a href="/products/beta">产品 B</a></li>
</ul>
</details>
</li>
<li>
<details class="has-submenu">
<summary class="menu-summary">资源</summary>
<ul class="submenu">
<li><a href="/guides/">使用指南</a></li>
<li><a href="/api/">API 文档</a></li>
<li><a href="/support/">帮助中心</a></li>
</ul>
</details>
</li>
<li>
<a class="nav-link" href="/about">关于我们</a>
</li>
</ul>
</nav>
</header>
<main>
<h1>欢迎访问示例网站</h1>
<p>点击或使用键盘聚焦“产品”和“资源”,即可展开对应的子菜单。选择子菜单中的链接会跳转到相应页面。</p>
</main>
</body>
</html>
三、代码中几个关键设计
用链接表达跳转,用 summary 表达展开
顶层的“首页”和“关于我们”是链接,因为它们直接跳转页面。带子菜单的“产品”和“资源”则用 <summary> 作为展开控件,并由 <details> 提供打开、关闭状态。浏览器会为 <summary> 提供原生交互,通常可以用鼠标、触屏和键盘操作,也会向辅助技术暴露展开状态。
这种写法有一个实际取舍:“产品”本身不再是直接跳转链接。如果产品栏目既需要展开子菜单,又需要点击标题进入总览页,不应简单地把链接塞进 <summary>,因为这会让交互和可访问性变得不清晰。可将“产品概览”作为子菜单第一项,或将标题拆分成独立链接和展开按钮,并仔细处理布局、名称和键盘行为。
不只依赖悬停
许多传统菜单只写 :hover,鼠标移入时才显示子菜单。触屏设备没有稳定的悬停行为,键盘用户也无法靠鼠标移动展开菜单。示例依靠原生的 <details> 状态控制显示,因此点击、触摸和键盘操作使用的是同一套交互,不需要重复维护一套 JavaScript 状态。
示例中的 position: absolute 用于桌面端把子菜单放在导航项下方;较窄屏幕下则切换为 position: static,让子菜单进入正常布局流。这能降低菜单覆盖正文、超出屏幕边缘的风险。具体断点应结合实际导航内容和测试结果调整,而不是把某个像素值当作所有网站的通用标准。
保留可见焦点
:focus-visible 为键盘用户提供清晰的焦点轮廓。不要为了追求视觉简洁就写 outline: none 而不提供替代样式。焦点轮廓应与背景有足够区分,并且不能被容器裁切。示例也对链接悬停和当前页面提供背景与颜色提示;正式站点还应验证颜色对比度,并避免只用颜色表达状态。
导航区域要有名称
<nav aria-label="主导航"> 给导航地标提供了可识别的名称。如果页面有多个导航区,例如页头导航和页脚导航,应使用不同且准确的名称,方便屏幕阅读器用户区分。链接文本也要能说明目的地,尽量避免在多个位置重复使用含义不明的“点击这里”。
aria-current="page" 用来表示当前页面对应的导航链接。示例把它放在首页链接上只是演示;实际页面应由模板或前端路由根据当前地址设置到正确的链接上。不要把它固定到首页之外的所有页面,也不要把一个菜单中的每个链接都标成当前页面。
四、适用边界与可访问性检查
原生 <details> 适合折叠说明、简单的展开列表和本例这类轻量导航。在现代主流浏览器中通常可以正常使用,但正式上线仍应按照项目支持的浏览器范围做测试。如果产品必须兼容较老的浏览器、要求点击其他菜单时自动关闭当前菜单,或需要路由切换后关闭菜单,可能需要 JavaScript 来补充行为。
本例没有实现“点击页面其他位置关闭菜单”“按 Escape 关闭所有菜单”以及“打开一个时自动关闭其他菜单”。由于 <details> 是原生控件,浏览器和辅助技术的具体行为会有差异。若产品需求明确要求这些行为,需要在目标浏览器、键盘和屏幕阅读器组合上验证,再谨慎增加脚本;脚本不能只处理鼠标事件,还应维护正确的展开状态、焦点位置和关闭行为。
如果用 JavaScript 自制下拉菜单,至少要确认以下问题:展开控件能通过键盘操作;当前展开状态可被辅助技术识别;关闭菜单后焦点不会丢失到不可预期的位置;菜单项是正常的网页链接;触屏用户可以明确打开和关闭;路由跳转、页面滚动和窗口尺寸变化时状态仍合理。不要仅凭添加 role="menu" 就认为菜单变得可访问。ARIA 角色不会自动提供键盘行为,应用菜单角色通常意味着还要实现相应的方向键、焦点移动等模式。
菜单项也不宜无限增加。窄屏上导航会变长,子菜单可能超出视口;桌面上若菜单紧贴浏览器边缘,也可能被裁切。需要针对较长标签、缩放页面、不同语言和小屏设备做测试。导航内容要能随字体放大而扩展,避免固定宽度和固定高度把文字截断。
五、常见错误及修正思路
错误一:只有鼠标悬停才能打开
这种实现排除了触屏和许多键盘操作方式。应提供可聚焦的展开控件,并让它通过点击或键盘激活。即使保留悬停作为桌面端的辅助行为,也不应把它作为唯一入口。
错误二:把整块标题做成不明确的混合控件
同一个区域既跳转又展开,会让用户难以预测点击结果。明确划分职责:要么标题只展开,子菜单提供总览链接;要么把标题链接和独立的展开按钮分开,并为两个控件提供清晰名称和焦点样式。
错误三:子菜单被父容器裁切
绝对定位的菜单可能受到祖先元素的 overflow: hidden 或滚动容器影响,即使设置很大的 z-index 也无法越过某些裁切边界。排查时检查相关祖先的溢出、定位和堆叠上下文;优先调整布局结构,而不是不断增大层级数值。移动端改用文档流布局通常更稳妥。
错误四:用空链接或错误的链接地址占位
href="#" 通常会改变页面片段位置,容易让页面跳动,也不能作为真实页面地址的替代品。尚未确定目的地时,可以先不要呈现为链接,或在内容模型中明确标记未发布状态;发布前应检查每个菜单项确实有有效目的地。
错误五:只靠颜色区分悬停和当前状态
对比不足的颜色变化可能难以辨认,也不一定能被所有用户感知。除了颜色,可以组合背景、下划线、字重或形状等提示。焦点状态必须清晰,并在实际背景、缩放和高对比度环境下检查。
错误六:为复杂行为随意添加 ARIA
属性不是交互逻辑的替代品。只有在语义确实需要时才添加 ARIA,并确认属性值会随状态变化。对普通网站导航,原生列表、链接和折叠控件通常比手工拼出的角色组合更容易维护。错误或多余的属性反而可能让辅助技术获得互相矛盾的信息。
六、参数、输入和安全边界
示例没有接受用户输入,也没有提供可配置的 JavaScript 参数。max-width、padding 和媒体查询中的宽度属于 CSS 样式值,应根据布局设计调整;它们不是具有安全语义的参数,也不能用于限制菜单链接的访问权限。调整这些数值不会验证地址、过滤用户输入或阻止攻击。
示例中的 href 是静态路径。真实项目中,菜单地址可能来自 CMS、数据库或配置文件。不要把未经验证的用户输入直接拼接到链接地址、HTML 属性或页面内容中。对可能变化的地址,应按照业务规则限制允许的协议和站点;尤其不能默认把任意字符串当成可信 URL。HTML 转义应按输出上下文执行:文本节点、属性值、JavaScript 和 CSS 不是同一种上下文,单纯替换尖括号不能代替完整的安全处理。
本例没有在新标签页打开外部链接,因此不需要设置 target="_blank"。如果业务确实需要新标签页,通常应使用 rel="noopener noreferrer",并考虑告知用户链接会在新窗口或标签页打开。不要把这两个属性误解为 URL 白名单或完整的内容安全策略,它们解决的是特定的链接上下文问题。
隐藏子菜单只是视觉和交互状态,不是访问控制。把某个链接从导航里移除、用 CSS 隐藏,或不给普通用户展示入口,都不能阻止用户直接访问对应地址。涉及权限的数据和页面必须由服务器端或可信的后端服务校验;前端导航只能帮助用户发现可用页面,不能承担授权职责。
同样,aria-label、aria-current 和焦点样式用于改善结构和交互,不提供认证、授权或数据保护。不要把页面上的菜单状态当作安全边界。是否允许访问某个资源,应在每次相关请求中按照应用的身份认证和权限规则判断。
七、上线前检查清单
- 不使用鼠标时,能否通过键盘找到并展开每个子菜单?焦点是否清楚可见?
- 触屏设备上,展开控件是否容易点击,展开后是否能看到所有子项?
- 所有菜单项的链接是否有效,名称是否能说明目的地?当前页面状态是否准确?
- 浏览器缩放、较长文本、小屏宽度和不同语言下,菜单是否溢出、重叠或被裁切?
- 是否检查了目标浏览器、辅助技术以及有无 JavaScript 时的实际行为?
- 链接和菜单数据若来自外部输入,是否经过符合业务规则的校验与上下文正确的输出处理?
- 需要保护的页面是否由服务端授权,而不是依赖前端隐藏菜单?
制作下拉导航的核心不只是让子菜单“出现”,还要让用户知道如何打开、如何选择,以及页面怎样响应。先确定导航层级和链接目的,再选择原生语义与布局方式;随后分别验证键盘、触屏、小屏和真实链接数据。对于简单网站导航,原生 HTML 往往足够;需要复杂状态时再引入脚本,并把可访问性和服务器端权限边界一并纳入设计。



