
响应式导航条代码示例:适配手机和电脑屏幕
导航条是页面结构的一部分,不只是把几个链接排成一行。电脑屏幕通常有足够空间展示完整菜单;手机屏幕较窄,链接容易换行、挤在一起,或占去首屏的大量空间。响应式导航条要解决的,正是不同视口下的布局与操作问题,同时保证链接可发现、可访问,并且在没有 JavaScript 时有明确的退路。
本文给出一个可直接保存为 HTML 文件运行的示例:宽屏时显示横向导航,窄屏时显示“菜单”按钮,点击后展开链接。示例使用原生 HTML、CSS 和 JavaScript,不依赖框架,也不包含第三方资源。它适合站点级的少量一级导航;若项目需要复杂的多级菜单、搜索、用户账户区或复杂的焦点管理,应根据实际交互需求扩展,而不是把所有功能都塞进一个汉堡菜单。
一、适用场景与设计边界
响应式导航适合拥有相对稳定的一组站点入口的页面,例如企业网站、产品文档、博客或活动页面。常见做法是桌面端直接呈现链接,手机端将链接收进一个由按钮控制的菜单。断点应由内容决定:当链接开始拥挤、挤压品牌区域或出现不自然换行时,再切换布局。示例使用 48rem 作为断点,它只是一个设计选择,并非浏览器规定或适用于所有项目的固定值。
菜单项较多时,压缩成手机菜单并不会自动改善信息架构。优先区分主要入口与次要入口、合并重复链接,并考虑是否需要独立的目录页。复杂下拉菜单还涉及触屏点击、键盘方向键、焦点移动、关闭规则和读屏反馈;下面的示例没有实现这些行为,因此不应把它当作完整的多级菜单组件。
另一个边界是内容是否依赖 JavaScript 才能访问。示例在窄屏下默认收起菜单,并由 JavaScript 展开。如果脚本被禁用或执行失败,窄屏用户无法通过按钮打开导航。对于要求无脚本仍能完整访问的站点,可以采用默认展示链接、由脚本增强为可折叠菜单的渐进增强设计,或确保页面另有清晰的站点导航入口。
二、完整可运行示例
将下面代码保存为 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: #1f2937;
background: #fff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
line-height: 1.5;
}
.skip-link {
position: absolute;
left: 1rem;
top: -4rem;
z-index: 10;
padding: .65rem .9rem;
color: #fff;
background: #111827;
border-radius: .25rem;
}
.skip-link:focus {
top: 1rem;
}
.site-header {
border-bottom: 1px solid #d1d5db;
background: #fff;
}
.header-inner {
width: min(100% - 2rem, 72rem);
min-height: 4rem;
margin-inline: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
color: #111827;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
}
.menu-button {
padding: .55rem .8rem;
border: 1px solid #6b7280;
border-radius: .35rem;
color: #111827;
background: #fff;
font: inherit;
cursor: pointer;
}
.menu-button:focus-visible,
.primary-nav a:focus-visible,
.brand:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 3px;
}
.primary-nav {
display: none;
position: absolute;
top: 4rem;
left: 0;
right: 0;
z-index: 5;
padding: .5rem 1rem 1rem;
border-bottom: 1px solid #d1d5db;
background: #fff;
}
.primary-nav.is-open {
display: block;
}
.primary-nav ul {
width: min(100%, 72rem);
margin: 0 auto;
padding: 0;
list-style: none;
}
.primary-nav a {
display: block;
padding: .75rem .5rem;
border-radius: .25rem;
color: #1d4ed8;
text-decoration: none;
}
.primary-nav a:hover {
background: #eff6ff;
text-decoration: underline;
}
main {
width: min(100% - 2rem, 72rem);
margin: 2rem auto;
}
@media (min-width: 48rem) {
.header-inner {
min-height: 4.5rem;
}
.menu-button {
display: none;
}
.primary-nav,
.primary-nav.is-open {
display: block;
position: static;
padding: 0;
border: 0;
}
.primary-nav ul {
width: auto;
display: flex;
align-items: center;
gap: .25rem;
}
.primary-nav a {
padding: .6rem .75rem;
}
}
</style>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<header class="site-header">
<div class="header-inner">
<a class="brand" href="/">示例站点</a>
<button
class="menu-button"
type="button"
aria-expanded="false"
aria-controls="primary-navigation">
菜单
</button>
<nav class="primary-nav" id="primary-navigation"
aria-label="主导航">
<ul>
<li><a href="/" aria-current="page">首页</a></li>
<li><a href="/services/">服务</a></li>
<li><a href="/about/">关于我们</a></li>
<li><a href="/contact/">联系我们</a></li>
</ul>
</nav>
</div>
</header>
<main id="main-content" tabindex="-1">
<h1>页面标题</h1>
<p>将窗口缩窄,点击“菜单”按钮测试响应式导航。</p>
</main>
<script>
const button = document.querySelector(".menu-button");
const nav = document.querySelector("#primary-navigation");
const desktopQuery = window.matchMedia("(min-width: 48rem)");
function setMenuOpen(open) {
nav.classList.toggle("is-open", open);
button.setAttribute("aria-expanded", String(open));
button.textContent = open ? "关闭菜单" : "菜单";
}
button.addEventListener("click", () => {
const isOpen = button.getAttribute("aria-expanded") === "true";
setMenuOpen(!isOpen);
});
nav.addEventListener("click", (event) => {
if (event.target.closest("a") && !desktopQuery.matches) {
setMenuOpen(false);
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape"
&& button.getAttribute("aria-expanded") === "true") {
setMenuOpen(false);
button.focus();
}
});
desktopQuery.addEventListener("change", () => {
setMenuOpen(false);
});
</script>
</body>
</html>
三、代码如何工作
1. 用视口元信息匹配设备宽度
meta name="viewport" 让移动浏览器按设备视口宽度布局。缺少它时,一些移动浏览器可能使用较宽的虚拟布局视口,导致媒体查询和页面缩放表现不符合预期。这里的 width=device-width 是常见设置;它不是用来禁止缩放的。不要为了让布局看起来“固定”而添加 user-scalable=no 或过度限制缩放,这会妨碍低视力用户放大内容。
2. 窄屏收起,宽屏直接显示
默认样式让导航列表隐藏,按钮保持可见;按钮点击后,脚本为导航增加 is-open 类,CSS 才显示菜单。在 48rem 及以上的视口宽度中,媒体查询始终展示导航并隐藏按钮。rem 与像素的换算受根字号影响,所以不要假设这个断点永远精确对应某一款设备;应实际检查内容在不同宽度、字体缩放和浏览器缩放下的表现。
媒体查询里同时设置了 .primary-nav 与 .primary-nav.is-open 的显示方式,避免窄屏遗留的展开状态或后续样式覆盖造成桌面布局异常。窗口跨过断点时,JavaScript 也会把状态重置为关闭;桌面端不需要依赖这个状态,因为 CSS 会直接显示链接。
3. 按钮、导航地标与状态
展开控件使用原生 <button>,而不是用带点击事件的 <div>。按钮自带键盘操作语义,脚本通过 aria-expanded 报告展开状态,并通过 aria-controls 指向受控导航。导航元素带有 aria-label,便于区分页面中可能存在的多个导航区域;若页面只有一个导航,也可以按项目的无障碍规范决定是否保留标签。
链接保留真实的导航语义,当前页面链接用 aria-current="page" 标记。不要把整个菜单做成一个 ARIA menu,除非确实需要实现应用菜单模式所要求的键盘交互;普通网站导航通常应使用导航地标和普通链接,不必套用复杂的菜单角色。
4. 关闭行为与焦点
手机端点击导航链接后,示例收起菜单,适合链接会跳转到新页面的情形。按 Escape 会收起菜单并把焦点还给按钮。脚本没有在打开时强行把焦点移动到第一个链接,因此键盘用户可以从按钮继续按 Tab 浏览;如果组件采用模态遮罩、阻止背景操作或把焦点限制在菜单内,就属于另一种交互模式,需要完整处理焦点进入、退出和背景可操作性,不能只加一个遮罩。
菜单展开状态通过按钮的可访问名称“菜单”和“关闭菜单”体现。实际项目可以改用图标,但应确保按钮仍有清楚的可访问名称,且状态仍可被辅助技术识别。图标若只是装饰,应避免让读屏器重复读出无意义字符。
四、落地时需要检查的关键边界
- 导航链接是否真实可用:示例中的
/services/等路径只是占位符。应替换为真实路由或页面地址,避免使用href="#"冒充链接。空锚点可能导致页面跳到顶部,也不能表达真实目的地。 - 窄屏菜单是否遮挡内容:示例采用绝对定位的下拉面板,会覆盖页面正文。若项目希望菜单展开时把正文向下推,应改用正常文档流布局。若需要遮罩,还应设计点击遮罩关闭、滚动策略和焦点行为。
- 较长的品牌名和菜单项:示例为品牌名设置了不换行,但空间不足时仍可能挤压导航。真实项目应测试长文本、较大字体、浏览器缩放和翻译后的文本长度,必要时允许换行或调整布局。
- 脚本执行时机:示例脚本位于 body 末尾,因此查询元素时页面结构已经出现。若把脚本移到 head,需使用
defer或在 DOM 构建后执行,避免查找元素时得到null。 - 多套导航实例:示例选择器针对单个导航。页面中若有多个独立菜单,不应复制同一组固定 ID 后继续使用全局查询;应为每组组件提供唯一 ID,并按组件范围绑定按钮与导航。
五、常见错误及修正方法
只用 CSS 隐藏菜单,没有可操作的触发器
移动端把列表设为 display: none,却没有按钮或其他显式控件,用户就无从展开。相反,只在视觉上把菜单移出屏幕但仍留在键盘顺序或辅助技术阅读顺序中,也会产生不一致。可见状态、键盘可达性和读屏状态应保持一致;本例通过显示规则、按钮状态和导航结构共同实现这一点。
按钮看起来能点,实际却不是按钮
用 span 或 div 监听点击事件,往往还要补键盘焦点、Enter/空格操作和角色属性,容易遗漏。优先使用原生按钮,并保留可见的焦点样式。不要通过 outline: none 一概移除焦点指示;若要自定义,应提供足够醒目的替代样式。
只更新外观,不更新展开状态
点击后改了 CSS,却没有同步 aria-expanded,辅助技术读到的状态就可能与界面不一致。反过来,仅更新 ARIA 属性而不改变菜单显示,也同样不正确。让状态更新集中在一个函数中,减少多个事件分支各自改样式、改文字和改属性造成的不同步。
用固定高度或设备型号判断布局
导航是否拥挤主要取决于内容宽度,不取决于设备名称。按某款手机的型号切断点,无法覆盖浏览器窗口缩放、平板分屏、系统字体变大等情况。应从窄视口开始测试,逐渐加宽,找到布局能够自然切换的范围,并在断点附近反复检查。
把“菜单”做成模态框却没有模态行为
如果展开面板只是普通页面导航,不要随意给它加 aria-modal 或对背景使用模态语义。若确实要做全屏模态导航,就需要考虑焦点管理、关闭方式、背景内容是否可操作及读屏器的上下文。只添加 ARIA 属性并不会自动获得这些行为。
六、参数、兼容性与安全说明
本例没有接收用户输入,也没有调用外部 API、加载第三方脚本或在页面中拼接 HTML 字符串。媒体查询的 48rem 是可调的布局参数,不是安全限制;aria-expanded 是状态信息,不会控制权限;aria-controls 只是关联元素的标识,不会自动显示或隐藏目标。上述属性不能替代 JavaScript 状态逻辑或 CSS 布局规则。
代码用 event.target.closest("a") 判断导航内是否点击了链接。此写法依赖现代浏览器对 Element.closest 的支持。若项目必须支持较旧浏览器,应结合目标浏览器范围检查兼容性,必要时提供替代实现或经过审查的 polyfill。示例使用 MediaQueryList.addEventListener("change", ...) 监听断点变化;旧环境也可能需要兼容处理。不要在未明确浏览器支持范围的情况下,把“现代浏览器可运行”描述成“所有设备均支持”。
安全方面,固定的导航地址可以直接写在 HTML 中。若链接来自 CMS、用户配置或数据库,应在内容进入页面前按应用规则校验和输出;不要把未经验证的字符串拼进 href,尤其要防止不可信的脚本协议等危险地址。若由 JavaScript 写入链接或文本,优先使用安全的 DOM 属性与 textContent;只有在确有需要时才使用 HTML 解析,并对内容进行符合上下文的处理。示例没有使用 innerHTML,但这不代表任意外部链接都天然可信。
此外,链接目标、登录权限与服务端访问控制必须由实际路由和后端保证。把某个入口从导航中隐藏,不会让对应资源变得安全,也不能代替授权检查。导航只负责提供访问入口,不负责保护页面或数据。
七、测试清单
- 在窄屏打开页面,确认菜单默认收起,按钮可以用鼠标、触屏和键盘激活。
- 确认按钮状态在展开、收起时同步变化;按 Escape 后菜单关闭且焦点回到按钮。
- 点击手机菜单内的链接,确认导航按预期发生并收起菜单。
- 在断点上下调整窗口宽度,确认桌面端始终显示链接、按钮不再出现,且切换过程中没有残留的错误状态。
- 放大页面、增加系统字体,或使用较长的菜单文案,检查文字是否被裁切、控件是否重叠。
- 使用键盘 Tab 遍历页面,确认焦点顺序合理、焦点可见,并且跳转到主要内容的链接可用。
- 检查当前页面标记、菜单链接地址和页面标题,确保示例占位内容已替换。
- 用项目支持的浏览器和读屏软件做实际验证;静态检查或单一浏览器测试不能覆盖所有辅助技术组合。
响应式导航条的核心不是某个特定断点或汉堡图标,而是让布局随可用空间调整,让控件语义与实际状态一致,并明确组件支持的交互范围。先用简单的一级导航满足常见需求,再依据真实内容、目标浏览器和无障碍测试结果扩展功能,通常比一开始引入复杂的菜单系统更容易维护。



