引言:“就做个简单菜单”,每个产品需求文档都这么说
流程你懂的:市场部想要一个大型美观的导航栏,包含多列布局、图标、促销内容,可能还得加个表单,而且“在移动端要感觉流畅自然”。一小时后,你就深陷Javascript事件处理程序和悬停/聚焦/滚动的竞态条件中无法自拔。
好消息是:对于大多数大型菜单来说,你根本不需要Javascript。借助语义化HTML、现代CSS(如:focus-within、:has()、网格/弹性布局、容器查询)和一些渐进增强技巧,你可以构建出具备以下特性的大型菜单:
- 对键盘和屏幕阅读器用户友好
- 在各种断点下保持响应式
- 易于维护(纯CSS,没有复杂的事件逻辑)
- 高性能(没有Javascript监听器导致的布局重排)
本指南将带你了解一个可直接用于生产环境的模式——桌面端“悬停/打开”+键盘支持,以及移动端“点击打开”且具备无障碍友好的手风琴效果——全部使用纯CSS实现。你将获得可直接复制的代码、清晰的设计思路以及可调节的变量。
大型菜单
我们要构建什么(以及为什么这种方案可行)
我们将创建一个顶部导航栏,包含几个顶级菜单项。其中一些会展开为大型面板(多列菜单、图标、促销卡片),另一些则是简单的链接。在桌面端,面板会在悬停或键盘聚焦时打开;在移动端,面板会变成手风琴式区域,通过原生方式切换(使用<details>/<summary>标签),全程无需Javascript。
我们将运用的关键概念:
- 使用**position: sticky**实现滚动时固定的头部(可选)
- 使用**:focus-within**实现键盘操作下的面板展开
- 使用**:has()**(在支持的浏览器中)简化状态选择器(并提供降级方案)
- 使用CSS网格布局大型面板
- 使用容器查询(可选)微调内部面板布局
- 使用**details/summary**实现移动端点击展开,且具备内置无障碍支持
兼容性说明:**:has()**目前在现代浏览器中已获得广泛支持。我们会提供不使用:has()的降级方案。如果你无法使用:has(),仍然可以通过:hover+ :focus-within组合实现基本功能。
1) 基础HTML:语义化、简洁且可扩展
我们将编写一个适用于桌面和移动端的单一语义化结构。针对移动端,我们将使用由<details>元素驱动的仅限移动端的二级导航,以避免使用Javascript。桌面端则采用传统的**<nav><ul>**模式。
<header class="site-header"> <div class="container"> <a class="brand" href="/">Acme</a> <!-- Desktop nav --> <nav class="nav-desktop" aria-label="Main"> <ul class="menu"> <li class="menu-item has-mega"> <a class="menu-link" href="/products" aria-haspopup="true" aria-expanded="false">Products</a> <div class="mega" role="region" aria-label="Products"> <div class="mega-grid"> <section class="mega-col"> <h3 class="mega-heading">Build</h3> <ul class="mega-list"> <li><a href="/products/editor">Editor</a></li> <li><a href="/products/cli">CLI</a></li> <li><a href="/products/sdk">SDK</a></li> </ul> </section> <section class="mega-col"> <h3 class="mega-heading">Ship</h3> <ul class="mega-list"> <li><a href="/products/deploy">Deploy</a></li> <li><a href="/products/monitoring">Monitoring</a></li> <li><a href="/products/logs">Logs</a></li> </ul> </section> <section class="mega-col highlight"> <h3 class="mega-heading">New</h3> <a class="promo-card" href="/launch"> <img src=https://news.ytian678.com/skin/default/image/nopic.gif alt="" /> <div class="promo-content"> <strong>Acme Launch</strong> <p>Zero-downtime rollouts with traffic shaping.</p> <span class="promo-cta">Learn more →</span> </div> </a> </section> </div> </div> </li> <li class="menu-item has-mega"> <a class="menu-link" href="/solutions" aria-haspopup="true" aria-expanded="false">Solutions</a> <div class="mega" role="region" aria-label="Solutions"> <div class="mega-grid"> <section class="mega-col"> <h3 class="mega-heading">Teams</h3> <ul class="mega-list"> <li><a href="/solutions/startups">Startups</a></li> <li><a href="/solutions/enterprise">Enterprise</a></li> <li><a href="/solutions/education">Education</a></li> </ul> </section> <section class="mega-col"> <h3 class="mega-heading">Use Cases</h3> <ul class="mega-list"> <li><a href="/solutions/edge">Edge</a></li> <li><a href="/solutions/ai">AI</a></li> <li><a href="/solutions/ecommerce">eCommerce</a></li> </ul> </section> </div> </div> </li> <li class="menu-item"><a class="menu-link" href="/pricing">Pricing</a></li> <li class="menu-item"><a class="menu-link" href="/docs">Docs</a></li> </ul> </nav> <!-- Mobile nav --> <nav class="nav-mobile" aria-label="Main (mobile)"> <details> <summary class="mobile-summary">Products</summary> <div class="mobile-panel"> <a href="/products/editor">Editor</a> <a href="/products/cli">CLI</a> <a href="/products/sdk">SDK</a> <hr> <a href="/products/deploy">Deploy</a> <a href="/products/monitoring">Monitoring</a> <a href="/products/logs">Logs</a> <hr> <a class="promo" href="/launch">Acme Launch →</a> </div> </details> <details> <summary class="mobile-summary">Solutions</summary> <div class="mobile-panel"> <a href="/solutions/startups">Startups</a> <a href="/solutions/enterprise">Enterprise</a> <a href="/solutions/education">Education</a> <hr> <a href="/solutions/edge">Edge</a> <a href="/solutions/ai">AI</a> <a href="/solutions/ecommerce">eCommerce</a> </div> </details> <a class="mobile-link" href="/pricing">Pricing</a> <a class="mobile-link" href="/docs">Docs</a> </nav> <!-- Mobile menu toggle (pure CSS) --> <input type="checkbox" id="nav-toggle" class="nav-toggle" aria-hidden="true" /> <label class="nav-toggle-btn" for="nav-toggle" aria-label="Toggle Menu"> <span></span><span></span><span></span> </label> </div></header>为什么要用两个导航结构?
- 桌面端提供完整的大型面板,支持悬停和键盘操作。
- 移动端通过<details>实现原生展开/折叠功能——默认具备无障碍性,无需Javascript。
- 两个导航结构指向相同的URL,因此不影响SEO和爬虫抓取。
你也可以使用一个导航结构并通过渐进方式添加样式,但采用两个导航结构的方式能让CSS更简洁,避免复杂的条件选择器。
2) 全局变量和重置样式
我们将使用CSS变量来管理间距、颜色和尺寸。这样调整起来会非常方便。
:root { --header-height: 64px; --container: 1200px; --z-header: 50; --bg: #0b1020; --surface: #0f152b; --text: #eef2ff; --muted: #9aa3b2; --accent: #6aa3ff; --border: #1c2548; --radius: 12px; --gap: 16px; --shadow: 0 10px 30px rgba(0,0,0,.25);}* { box-sizing: border-box; }html, body { height: 100%; }body { margin: 0; font: 500 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; color: var(--text); background: var(--bg);}a { color: inherit; text-decoration: none; }a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px;}3) 头部框架和响应式切换
我们将在大视口显示桌面导航,在小视口显示移动导航。汉堡菜单通过一个复选框(纯CSS方式)控制移动端抽屉的展开/折叠。
.site-header { position: sticky; top: 0; z-index: var(--z-header); background: rgba(11,16,32,0.7); backdrop-filter: blur(10px); border-bottom: 1px solid var(--border);}.site-header .container { max-width: var(--container); margin: 0 auto; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: var(--gap); padding: 0 16px; height: var(--header-height);}.brand { font-weight: 800; letter-spacing: .3px;}.nav-desktop { display: none; }.nav-mobile { display: none; }.nav-toggle { display: none; }.nav-toggle-btn { width: 36px; height: 28px; display: grid; gap: 6px; cursor: pointer;}.nav-toggle-btn span { display: block; height: 3px; background: var(--text); border-radius: 3px;}@media (min-width: 960px) { .site-header .container { grid-template-columns: auto 1fr; height: var(--header-height); } .nav-desktop { display: block; } .nav-mobile, .nav-toggle-btn { display: none; }}4) 桌面端大型菜单:悬停 + 键盘支持(使用:focus-within)
我们将让顶级的<li>元素在其悬停时或包含焦点时(通过:focus-within)展开对应的.mega面板。这样可以覆盖鼠标、键盘和屏幕阅读器用户的使用场景。
.menu { list-style: none; margin: 0; padding: 0; display: flex; gap: 20px; align-items: stretch;}.menu-item { position: relative; }.menu-link { display: inline-flex; align-items: center; gap: 8px; height: var(--header-height); padding: 0 10px; color: var(--text); opacity: .9;}.menu-link:hover { opacity: 1; }.mega { position: absolute; left: 50%; top: calc(100% - 1px); transform: translateX(-50%) translateY(8px); min-width: min(960px, 92vw); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .15s ease, transform .2s ease, visibility .2s;}.menu-item:hover > .mega,.menu-item:focus-within > .mega { opacity: 1; visibility: visible; pointer-events: auto; transform: translateX(-50%) translateY(0);}.mega-grid { display: grid; gap: 24px; grid-template-columns: repeat(3, minmax(0,1fr));}.mega-col { display: grid; gap: 10px; }.mega-heading { margin: 0 0 6px; font-size: 14px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);}.mega-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px;}.mega-list a { display: inline-flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; color: var(--text); background: transparent;}.mega-list a:hover { background: rgba(255,255,255,.05); }.promo-card { display: grid; grid-template-columns: 96px 1fr; gap: 10px; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.01));}.promo-card img { width: 100%; height: 100%; object-fit: cover; display: block; }.promo-content { padding: 10px; display: grid; gap: 6px; }.promo-cta { color: var(--accent); font-weight: 600; }@container (min-width: 640px) { .mega-grid { grid-template-columns: repeat(4, minmax(0,1fr)); }}提示: 可以在.mega或.mega-grid外层包裹器上使用@container查询,实现更精细的、组件级别的响应式设计——即使头部在不同页面具有不同宽度也能适应。
5) 使用:has()简化状态管理(渐进增强)
如果你的目标用户群体使用的浏览器支持:has(),你可以更新**aria-expanded**相关的样式,或者更直接地为父级状态设置样式。
@supports(selector(:has(*))) { .menu-item:has(:hover) > .menu-link, .menu-item:has(:focus-within) > .menu-link { color: var(--text); text-shadow: 0 0 0 currentColor; }}我们无法通过CSS直接切换aria-expanded的值,但我们可以在视觉上反映触发器处于“打开”状态,这对视力正常的用户来说是一个有用的反馈。
6) 移动端菜单使用<details>(点击展开,无需Javascript)
在小屏幕设备上,我们将通过汉堡菜单切换(纯CSS复选框)来显示移动端导航抽屉。每个部分都使用<details>标签实现原生展开/折叠功能,并具备内置的键盘支持。
.nav-mobile { display: grid; gap: 8px; position: fixed; inset: var(--header-height) 0 0 0; background: var(--surface); border-top: 1px solid var(--border); transform: translateY(-8px); opacity: 0; visibility: hidden; pointer-events: none; padding: 14px 16px 20vh; overflow: auto;}.nav-toggle:checked ~ .nav-mobile { transform: translateY(0); opacity: 1; visibility: visible; pointer-events: auto;}.nav-toggle:checked ~ .nav-toggle-btn span:nth-child(1) { transform: translateY(9px) rotate(45deg); }.nav-toggle:checked ~ .nav-toggle-btn span:nth-child(2) { opacity: 0; }.nav-toggle:checked ~ .nav-toggle-btn span:nth-child(3) { transform: translateY(-9px) rotate(-45deg); }.mobile-summary { padding: 12px 10px; border-radius: 8px; list-style: none; cursor: pointer; border: 1px solid var(--border);}.mobile-summary::-webkit-details-marker { display: none; }.mobile-panel { display: grid; gap: 8px; padding: 8px 10px 12px 10px;}.mobile-panel a { padding: 8px 10px; border-radius: 8px;}.mobile-panel a:hover { background: rgba(255,255,255,.05); }.mobile-link { padding: 12px 10px; border-radius: 8px; border: 1px solid var(--border);}.promo { color: var(--accent); font-weight: 600; }@media (min-width: 960px) { .nav-mobile { display: none; }}为什么要使用<details>?
- 它为你免费提供了原生的焦点管理、展开/折叠功能和语义化支持。
- 屏幕阅读器会自动播报“展开/折叠”状态。
- 你可以将其样式化为手风琴效果,并且它支持键盘操作。
7) 键盘和屏幕阅读器注意事项(这里没有捷径)
桌面端:
- Tab键导航::focus-within确保当键盘焦点落在触发器或其任意子链接上时,面板会立即打开。
- 退出机制:当用户将焦点移出面板时,面板会自动关闭(没有焦点就没有展开状态)。
- 阅读顺序:大型面板在DOM中紧接在其触发器之后。屏幕阅读器会自然地遇到它。
移动端:
- <summary>是一个具备内置语义的真正控件。
- 用户可以使用键盘和触摸操作来展开/折叠各个部分。
- 汉堡菜单切换是一个与视觉上隐藏的复选框相关联的带标签的<label>。复选框本身设置为aria-hidden,以避免在无障碍树中产生噪音。
你可以额外添加的功能:
- 地标区域:如果需要,可以用<nav aria-label="产品分类">包裹列表列。
- 标题:已经存在,有助于快速导航。
- 焦点陷阱:不需要,因为我们的菜单覆盖层不会锁定页面;用户可以Tab键移出。
8) 处理溢出、z-index和边缘情况
大型菜单经常会与页面其他内容重叠,所以我们要正确设置层级关系:
.site-header { z-index: var(--z-header); }.mega { z-index: calc(var(--z-header) + 1); } 如果你的头部位于一个设置了overflow: hidden的父容器内,面板可能会被裁剪。避免在祖先元素上设置overflow属性,或者将.mega面板渲染到一个传送门容器中(如果你使用Javascript的话)。在纯CSS方案中,确保头部的祖先元素保持overflow: visible。
面板内部滚动:
如果面板内容较多,可以添加以下样式:
.mega { max-height: min(70vh, 720px); overflow: auto; }鼠标离开触发器区域:
保持.mega面板靠近触发器。我们的位移距离很小;你还可以通过扩展可点击区域来使用悬停意图通道:
(此处为通过伪元素扩展悬停区域的CSS代码,保留原文代码块形式,不做翻译)
这条小条带可以防止鼠标在短暂离开链接和面板之间时导致悬停状态消失。
9) 主题和设计润色(几秒钟切换颜色)
因为我们抽象了设计令牌,所以可以快速切换主题:
.menu-item::after { content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 10px;}你可以通过设置document.documentElement.dataset.theme = 'light'来切换主题(如果你后续添加了Javascript),或者通过服务器端渲染一个data-theme="light"属性。
10) 可选功能:图标、徽章和微文案
大型菜单本质上是内容区域。把它们当作小型落地页来对待:
.mega-list a .badge { margin-left: auto; font-size: 12px; line-height: 1; padding: 4px 6px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted);}你可以添加徽章(如“新品”、“专业版”)、链接下方的简短描述,或者紧凑的图标。
.mega-list a { align-items: center;}.mega-list a svg { flex: 0 0 18px; width: 18px; height: 18px; opacity: .9;}11) 使用容器查询实现更智能的内部布局
如果大型面板的宽度因页面布局而异,容器查询可以让你的面板自动适应:
.mega { container-type: inline-size; }@container (max-width: 700px) { .mega-grid { grid-template-columns: repeat(2, 1fr); } .promo-card { grid-template-columns: 1fr; }}@container (max-width: 480px) { .mega-grid { grid-template-columns: 1fr; }}这样可以避免使用可能无法反映头部实际大小的全局断点。
12) 不使用:has()?依然可用。
我们的核心功能依赖于:hover和:focus-within。如果你移除:has()代码块,菜单仍然能够:
- 在悬停时展开面板
- 在通过键盘聚焦时展开面板
- 在触发器和链接上显示可视化的焦点环
:has()只是为“激活的触发器”添加了一些样式美化。可以安全地省略它。
13) 性能检查清单(为什么CSS方案更优)
- 零Javascript导致的布局重排(来自滚动或指针事件监听器)
- GPU友好的过渡效果(仅涉及透明度和变换)
- 小巧的CSS代码量:面板默认隐藏,不会引发大规模重排
- 更少的重绘(无需使用框架)
预加载关键资源: 如果你的促销卡片包含图片,可以为非关键图片添加fetchpriority="low"或使用loading="lazy"进行懒加载。
<img src=https://news.ytian678.com/skin/default/image/nopic.gif alt="" loading="lazy" decoding="async">14) 测试矩阵(为了你自己好,务必测试)
在发布前检查以下内容:
- 键盘操作:能否通过Tab键打开面板,在面板内使用方向键/Tab键导航,使用Shift+Tab键返回,面板是否会关闭?
- 屏幕阅读器:NVDA/JAWS/VoiceOver是否能正确读取结构和标题?
- 触摸操作:点击顶级项目时——桌面端面板不应要求二次点击才能跳转链接(我们仅在桌面端使用悬停,移动端使用<details>)
- 视口兼容性:iOS Safari(动态工具栏)、Android Chrome、Windows Chrome/Edge、macOS Safari/Chrome/Firefox
- 面板溢出:确保面板不会被父容器的overflow属性裁剪
- 焦点样式:在深色背景下是否可见?
15) 完整的最小化CSS代码包(可直接复制粘贴)
以下是一个精简版本,整合了核心功能。你可以将其粘贴到变量和重置样式之后:
@media (min-width: 960px) { .nav-desktop { display: block; } .nav-mobile, .nav-toggle-btn { display: none; } }@media (max-width: 959px) { .nav-desktop { display: none; } .nav-mobile, .nav-toggle-btn { display: block; } }.menu { list-style: none; margin: 0; padding: 0; display: flex; gap: 20px; }.menu-item { position: relative; }.menu-link { display: inline-flex; align-items: center; height: var(--header-height); padding: 0 10px; opacity: .9; }.menu-link:hover { opacity: 1; }.mega { position: absolute; left: 50%; top: calc(100% - 1px); transform: translateX(-50%) translateY(8px); min-width: min(960px, 92vw); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .15s, transform .2s, visibility .2s; z-index: calc(var(--z-header) + 1);}.menu-item:hover > .mega,.menu-item:focus-within > .mega { opacity: 1; visibility: visible; pointer-events: auto; transform: translateX(-50%) translateY(0);}.mega-grid { display: grid; gap: 24px; grid-template-columns: repeat(3, minmax(0,1fr)); }.mega-col { display: grid; gap: 10px; }.mega-heading { margin: 0 0 6px; font-size: 14px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }.mega-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }.mega-list a { display: inline-flex; gap: 8px; padding: 8px 10px; border-radius: 8px; }.mega-list a:hover { background: rgba(255,255,255,.05); }.promo-card { display: grid; grid-template-columns: 96px 1fr; gap: 10px; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }.promo-card img { width: 100%; height: 100%; object-fit: cover; display: block; }.promo-content { padding: 10px; display: grid; gap: 6px; }.promo-cta { color: var(--accent); font-weight: 600; }.nav-mobile { display: grid; gap: 8px; position: fixed; inset: var(--header-height) 0 0 0; background: var(--surface); border-top: 1px solid var(--border); transform: translateY(-8px); opacity: 0; visibility: hidden; pointer-events: none; padding: 14px 16px 20vh; overflow: auto;}.nav-toggle { display: none; }.nav-toggle-btn { width: 36px; height: 28px; display: grid; gap: 6px; cursor: pointer; }.nav-toggle-btn span { display: block; height: 3px; background: var(--text); border-radius: 3px; }.nav-toggle:checked ~ .nav-mobile { transform: translateY(0); opacity: 1; visibility: visible; pointer-events: auto; }.nav-toggle:checked ~ .nav-toggle-btn span:nth-child(1) { transform: translateY(9px) rotate(45deg); }.nav-toggle:checked ~ .nav-toggle-btn span:nth-child(2) { opacity: 0; }.nav-toggle:checked ~ .nav-toggle-btn span:nth-child(3) { transform: translateY(-9px) rotate(-45deg); }.mobile-summary { padding: 12px 10px; border-radius: 8px; border: 1px solid var(--border); cursor: pointer; }.mobile-summary::-webkit-details-marker { display: none; }.mobile-panel { display: grid; gap: 8px; padding: 8px 10px 12px 10px; }.mobile-panel a { padding: 8px 10px; border-radius: 8px; }.mobile-panel a:hover { background: rgba(255,255,255,.05); }.mobile-link { padding: 12px 10px; border-radius: 8px; border: 1px solid var(--border); }.menu-item::after { content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 10px; } .mega { max-height: min(70vh, 720px); overflow: auto; } 16) 你可能需要的变体(直接插入使用)
A. 右对齐面板(适用于最后一个菜单项)
避免面板在视口右侧溢出:
.menu-item.align-right > .mega { left: auto; right: 0; transform: translateY(8px);}.menu-item.align-right:hover > .mega,.menu-item.align-right:focus-within > .mega { transform: translateY(0);}B. 窄面板(简单的下拉菜单)
.menu-item.has-dropdown > .mega { min-width: 280px;}.menu-item.has-dropdown .mega-grid { grid-template-columns: 1fr;}C. 仅在滚动时为头部添加阴影(使用哨兵元素和:has())
纯CSS实现,当页面滚动时为头部添加阴影效果:
<!-- 将此“哨兵”元素放置在头部之后 --><div class="scroll-sentinel" aria-hidden="true"></div>.scroll-sentinel { position: absolute; top: var(--header-height); height: 1px; width: 1px; }@supports(selector(:has(*))) { body:has(.scroll-sentinel:below) .site-header { box-shadow: var(--shadow); }}一些浏览器不支持:has()与:below组合。你可以跳过这个美化功能,或者保留默认的细微边框效果。
17) 为什么这种模式适合团队协作
- 内容团队可以添加或删除列,无需触碰Javascript代码。
- 设计团队可以通过设计令牌轻松切换主题。
- 工程师可以避免处理mouseenter/mouseleave的时序问题。
- 无障碍性不会随着功能增加而退化(我们依赖原生的焦点和手风琴行为)。
如果你后续确实需要一些必须使用Javascript的功能(例如,记录面板的打开/关闭事件用于分析),你可以逐步增强功能——但你不会被迫一开始就依赖Javascript。
结论
大型导航菜单不必非得依赖Javascript。通过语义化HTML、:focus-within、可选的:has()、以及智能的布局原语(网格、容器查询),你可以使用纯CSS构建一个响应式、无障碍且精致的导航体验。
关键要点:
- 为不同设备定制两种体验:桌面端大型面板,移动端手风琴/详情页。
- 在桌面端通过悬停 + 键盘聚焦(使用:focus-within)打开面板。
- 使用网格和容器查询保持面板内部布局的灵活性。
- 注意z-index和溢出属性,避免面板被裁剪。
- 将所有设计元素令牌化,便于主题切换。
一旦你按照这种方式构建,你会疑惑为什么以前要为基本的展开/关闭功能而纠结于Javascript。
