
简介这是一份面向网页开发者的HTML菜单源码合集涵盖纯CSS基础菜单、结合JavaScript与jQuery的交互菜单以及配套的字体图标资源适合从入门到进阶的开发者快速搭建导航、下拉菜单等常见界面省去从零编写样式和脚本的时间。压缩包共38个文件大小约481KB包含13个less与13个scss样式源码、3个js交互脚本、2个css样式表以及多种字体格式文件woff2、ttf、svg、eot等便于按项目技术栈选择使用也适合研究样式预处理与菜单动效的写法。已有2595人学习下载。通过这套源码开发者可以掌握使用HTML结构配合CSS/JS/jQuery实现悬停下拉、平滑滚动、状态切换等交互效果的方法同时了解Font Awesome字体图标在菜单中的集成思路对提升前端页面交互质量与搭建效率都有直接帮助。1. 拿到漂亮精美的html菜单源码先别贴回答三个问题再动手在默认浏览器里打开别人给你的 demo漂亮精美 html 菜单源码几乎挑不出毛病阴影恰到好处、hover 过渡顺滑、图标也精致。可把这个页面整段复制进自己的项目问题就来了——点击父级目录页面直接跳走二级菜单在组件边缘一闪而过移动端点几下就没了响应。这不是源码质量问题而是“菜单源码”本质是一套特定条件下的代码片段超实用不等于拿来即用它能替你省掉一整块导航的排版和基础交互但省不掉适配、状态管理和可访问性的活。它适合两类人一类是需要马上出效果的官网、营销页一类是后台管理端想找个数据驱动菜单的从业者。开写之前先把“漂亮精美”放一边确认三件事这份源码用的是纯 CSS 还是 CSSJS菜单数据是写死还是可以换有没有键盘和移动端的状态处理。这三件事决定后面所有步骤也是这篇笔记要逐层拆开的内容。2. 菜单源码的三种常见风格与选型纯CSS、CSSJS、纯JS渲染怎么挑2.1 纯CSS菜单零依赖、跑得快代价是交互上限卡在hover拿到一份“菜单源码”先看包里的文件构成如果只有 html 和 css、没有 js那大概率是纯 CSS 实现。这类菜单的原理是把展开行为挂到:hover和:focus-within两个伪类上打开开发者工具看样式表常见写法是这样.submenu { opacity: 0; visibility: hidden; /* 先隐藏visibility 保留布局占位配合 opacity 做淡入 */ transition: opacity 0.2s ease; position: absolute; top: 100%; } .menu-item:hover .submenu, .menu-item:focus-within .submenu { opacity: 1; visibility: visible; }这段 CSS 的坑点其实在visibility上。如果直接用display: none元素从文档流里彻底移除opacity的过渡动画会直接失效菜单变成“唰”地一下出现而不是淡入visibility则保留了元素尺寸能在过渡结束后才真正拦截点击。0.2s 这个数值在鼠标端舒服在触屏上会显得拖沓建议移动端单独覆盖成 0.1s 或直接关闭过渡。纯 CSS 菜单的好处有三个没有初始化时序页面打开即就绪没有外部依赖不存在 CDN 挂了菜单就白板的玄学代码量最小出问题排查路径短。代价也明确交互上限卡在 hover 上触屏上第一次点击往往只是触发 hover第二次点击才能触发链接跳转。如果你做的是官网 banner 下的主导航用户主要用鼠标那纯 CSS 完全够用没必要上 JS。2.2 CSSJS菜单主流形态焦点管理和外层点击都要自己写纯 CSS 菜单遇到“点击父项才展开、再点其他区域收起”这类需求时就无法胜任于是出现了 CSSJS 的组合。这类菜单 demo 里动效最花哨但把它当成品用时有两个隐性成本其一点击外部关闭的逻辑必须自己写不然菜单打开后只能靠再次点击父级来切换其二键盘焦点顺序如果不处理Tab 键会直接飘到子菜单的隐藏链接上。注意这套逻辑和 Electron 里的原生菜单模板不是一回事浏览器菜单的收起逻辑永远要自己接管这一点后面第 5 章还会展开。事件委托是我在这个场景里唯一推荐的写法。不要给每个菜单项单独绑 click而是统一在 document 上挂一个监听document.addEventListener(click, function (event) { const item event.target.closest(.has-children); if (!item) { // 点击的是菜单以外的区域全部收起 document.querySelectorAll(.has-children.expanded).forEach(function (li) { li.classList.remove(expanded); const btn li.querySelector(button); if (btn) btn.setAttribute(aria-expanded, false); }); return; } item.classList.toggle(expanded); });这段代码的意思是事件先冒泡到 document判断点击目标是否落在某个带下拉的子容器里落在外面就全部收起落在某个容器里就切换它自己。closest会沿着祖先链往上找所以点击子菜单里的任意链接也能命中父容器。用事件委托的核心收益是后续通过接口动态新增菜单项不需要重新绑定监听器事件照样生效。2.3 选型对照与取舍建议菜单模板到底适配哪种项目很多人直接在 Bootstrap、Element UI 里找菜单那是组件库的一部分不是“菜单源码”。项目本来就没引组件库为一份菜单引入整套框架是捡芝麻丢西瓜的事。我这里说的“菜单模板”是指独立 HTMLCSSJS 文件组成的导航片段。实现方式依赖交互上限维护性典型场景纯CSS无hover 为主键盘支持弱结构简单状态靠伪类表达官网导航、静态站点CSSJS无外部依赖点击开合、外部点击关闭、焦点控制逻辑集中在组件内带下拉的官网、Web 系统纯JS渲染依赖数据接口动态增删、无限层级、接口驱动数据结构决定一切后台管理端、多租户系统我的选型标准很简单菜单项在 10 个以内、后续基本不变选纯 CSS省心菜单项会随权限动态变化或者后台要能配置菜单选纯 JS 渲染鼠标键盘混用、又要支持触屏选 CSSJS 并补上焦点管理。标题里的“menu 源码”在不同仓库里的实现风格差异很大先按这个框架筛一遍能省掉后面一大半调试时间。3. 把html菜单源码落到自己的页面最小结构与四个CSS变量3.1 先抄骨架一个能跑的最小菜单源码逐行解释节点层级先把一段能直接跑起来的最小菜单源码贴出来。它不是我编的复杂组件而是几乎所有漂亮菜单 demo 背后的公共骨架你拿到的源码大概率也是在这个骨架上加样式!doctype html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title最小可用菜单源码/title style /* 样式见 3.2 节 */ /style /head body nav classmenu aria-label主导航 ul classmenu-list li classmenu-item a href#home首页/a /li li classmenu-item has-children button typebutton aria-expandedfalse 产品 span classarrow aria-hiddentrue/span /button ul classsubmenu hidden lia href#desktop桌面端/a/li lia href#mobile移动端/a/li /ul /li li classmenu-item a href#price价格/a /li /ul /nav /body /html三个结构细节值得说。第一有下拉的父项用的是button而不是a原因很简单a自带导航行为点击就会跳转刷新菜单状态全丢button没有默认跳转展开收起完全交给 JS。第二hidden属性直接作为初始收起态它的默认样式是display: noneJS 只需要切换这个属性不需要维护一套 visible/invisible 类名。第三aria-expandedfalse和aria-hiddentrue是给读屏器读的前者告诉辅助技术这个按钮控制一个展开区域后者避免箭头符号被重复朗读。3.2 用CSS变量调“漂亮精美”主题色、圆角、阴影、间距一次说清菜单的“漂亮精美”几乎全在视觉参数上而视觉参数最该收敛成 CSS 变量否则你会在几十处颜色值里翻车。我一般会把四个变量单独拎出来:root { --menu-bg: #ffffff; --menu-radius: 8px; --menu-shadow: 0 6px 24px rgba(0, 0, 0, 0.12); --item-padding: 10px 18px; } .menu { background: var(--menu-bg); border-radius: var(--menu-radius); box-shadow: var(--menu-shadow); padding: 8px; } .menu-item button, .menu-item a { padding: var(--item-padding); border-radius: calc(var(--menu-radius) - 4px); }参数怎么调--menu-bg决定底色想走深色导航就把导航背景换成#1f2937这类深灰同时记得给文字和箭头换成浅色否则对比度不够--menu-radius控制圆角官网大 banner 下 0px 直角更干脆卡片式布局 8px 更柔和--menu-shadow控制悬浮感6px 24px 是常见的“浮起来”手感阴影太小时菜单会贴背景太大会显得像弹窗--item-padding是点击面积的关键垂直方向小于 8px 时手指点击很容易落空这是移动端最常被忽略的参数。如果拿到的源码用的是写死的颜色值没有变量体系不要全局替换直接把:root里的变量覆盖到body选择器上效果一样。部分源码会用!important遇到这种情况沿用 3.3 节的方式通过 JS 给菜单容器加覆盖类。3.3 JS事件初始化与参数事件委托、点击外部关闭、菜单模块搜索有了骨架和样式JS 只需要做三件事点击父项时切换展开点击外部时全部收起以及一个很多人会追加的“菜单模块搜索”过滤。不推荐用 jQuery原生就能写清楚const nav document.querySelector(.menu); nav.addEventListener(click, function (event) { const trigger event.target.closest(.has-children button); if (!trigger) return; event.stopPropagation(); const li trigger.parentElement; const sub li.querySelector(.submenu); const expanded trigger.getAttribute(aria-expanded) true; trigger.setAttribute(aria-expanded, String(!expanded)); sub.hidden expanded; }); document.addEventListener(click, function () { nav.querySelectorAll(.has-children).forEach(function (li) { const btn li.querySelector(button); btn.setAttribute(aria-expanded, false); li.querySelector(.submenu).hidden true; }); });stopPropagation的作用是让点击父项时事件不要冒泡到 document 的外层关闭逻辑否则菜单刚展开就被关掉。用aria-expanded作为状态源而不是 class是为了让读屏器和样式表共用一套状态。搜索过滤可以挂在input事件上searchInput.addEventListener(input, function () { const keyword searchInput.value.trim().toLowerCase(); nav.querySelectorAll(.menu-list .menu-item).forEach(function (item) { const text item.textContent.toLowerCase(); item.hidden keyword.length 0 text.indexOf(keyword) -1; }); });这里用item.textContent过滤会把子菜单的文字一起带上优点是输入“桌面”也能找到二级菜单里的内容缺点是有子菜单的父项常驻显示。菜单项一多这个搜索框就是刚需但注意它只是前端过滤数据量大时应该走接口查询那是第 6 章的内容。4. 多级菜单与折叠箭头从平铺HTML换成树形数据渲染4.1 树形数据驱动渲染别用双向链表的思路写前端菜单菜单项一多写死在 HTML 里的问题就暴露了改一个菜单项要同时改结构、样式、还有可能存在的脚本引用。更常见的做法是把菜单数据收成树形 JSON渲染时递归生成 DOM。这里提醒一句别用双向链表那套思路管理前端菜单浏览器 DOM 天然是树结构父查子直接用children比维护前驱、后继指针简单得多也不需要“双向链表多级菜单”那种写法。const menuTree [ { title: 首页, href: #home }, { title: 产品, children: [ { title: 桌面端, href: #desktop }, { title: 移动端, href: #mobile } ] }, { title: 价格, href: #price } ]; function renderMenu(items, parent) { const ul document.createElement(ul); ul.className menu-list; items.forEach(function (item) { const li document.createElement(li); li.className menu-item (item.children ? has-children : ); if (item.children) { const btn document.createElement(button); btn.type button; btn.setAttribute(aria-expanded, false); btn.innerHTML item.title span classarrow aria-hiddentrue/span; li.appendChild(btn); renderMenu(item.children, li); } else { const a document.createElement(a); a.href item.href || #; a.textContent item.title; li.appendChild(a); } ul.appendChild(li); }); parent.appendChild(ul); } renderMenu(menuTree, document.querySelector(#nav));注意两个边界叶子节点有href就渲染成a父节点无论有没有href都渲染成button保证点击不会触发页面跳转children字段缺省时按叶子处理接口返回的数据常常省略空数组渲染函数要兼容。递归终止条件就是item.children不存在层级深浅不必在函数里显式限制但建议渲染前先深拷贝并校验数据避免循环引用导致栈溢出。4.2 折叠箭头不用贴图CSS伪元素加旋转过渡和WinForm折叠箭头一个道理很多菜单源码里箭头用的是字体图标或图片一旦字体没加载箭头位置就会出现一个方块。更轻的做法是用 CSS 伪元素画箭头原理和 WinForm 里常见的菜单折叠箭头是同一种几何思路两段边框旋转 45 度得到角标。画法如下.arrow::before { content: ; display: inline-block; width: 6px; height: 6px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(45deg); transition: transform 0.2s ease; } .has-children.expanded .arrow::before { transform: translateY(2px) rotate(225deg); }rotate(45deg)让两条边框拼出一个指向右下方的角看起来像是“展开更多”的箭头菜单展开后旋转到 225 度箭头朝上。currentColor表示箭头颜色自动跟随按钮文字颜色这样深色主题下不用额外改色。这里有个容易踩的细节width和height必须显式设置否则伪元素没有尺寸边框画出来只有像素级的一条线。源码里若用的是图片箭头想换成伪元素记得删掉原来的img或i标签只保留这个span classarrow。4.3 移动端收起与展开汉堡按钮和max-height动画的边界移动端菜单的常规做法是屏幕宽度小于 768px 时菜单默认收成一个汉堡按钮点击后菜单从上往下展开。这里最容易翻车的不是按钮本身而是展开动画。很多人直接用max-height: 9999px来做平滑展开动画会快得离谱或者延迟严重因为浏览器要计算从 0 到 9999 的高度差。正确做法是给一个接近实际高度的值media (max-width: 768px) { .menu-list { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } .menu.open .menu-list { max-height: 480px; } }480px 对应大约 67 个一级菜单项的高度。如果菜单项多于 8 个实际高度超过 480px动画会在半路截断。一个技巧是先用 JS 量出scrollHeight再把max-height设成这个值但要注意改变窗口大小后需要重新计算。移动端还建议把 hover 展开全部关闭因为触屏没有 hover 态点一次按钮既要展开菜单又要防止误触链接所以父级按钮保持 button 形态叶子链接才用a这和 3.1 节的结构设计是呼应的。5. 菜单源码落地避坑从hover失灵到点击外部关不掉5.1 二级菜单在hover切换时“一闪而过”现象鼠标从一个父菜单移到二级菜单上二级菜单立刻消失怎么都移不过去。原因父菜单的:hover区域和子菜单之间有个空隙通常来自子菜单的margin-top或者父菜单的padding-bottom太小。鼠标离开父菜单瞬间hover 断掉子菜单就没了。解决子菜单不要用负 margin 做定位微调改成在子菜单内部加padding-top作为缓冲把它当成父菜单 hover 面积的延伸.submenu { top: 100%; padding-top: 10px; }这样鼠标从父菜单到子菜单的路径都在可 hover 区域内。另一种常见修复是把选择器从.menu-item:hover .submenu改成.menu-item:is(:hover, :focus-within) .submenu键盘 Tab 到子菜单时也不会丢。5.2 点击带下拉的父菜单页面直接跳走菜单状态全丢现象点击“产品”菜单页面刷新了菜单没展开。原因源码里父项用的是a href#products点击后浏览器执行默认导航。解决父菜单永远用button只有叶子链接用a如果你拿到的源码已经是a又不想改太多结构就在 JS 里统一拦截nav.addEventListener(click, function (event) { const trigger event.target.closest(.has-children a); if (trigger) { event.preventDefault(); // 后续再执行切换展开逻辑 } });拦截后需要手动管理 href 的跳转比如父项没有独立落地页时可以直接去掉 href 属性。别把这个逻辑写在a的 onclick 里菜单一多内联事件既难维护又容易漏。5.3 子菜单被兄弟模块盖住z-index设置了却始终不生效现象菜单展开后页面的轮播图或者弹层盖在子菜单上面把.submenu { z-index: 9999; }加上也没用。原因菜单的某个父级容器上设置了transform、opacity、filter或animation这些属性会在父级创建独立层叠上下文子元素的z-index只能在那个上下文内部比较。解决确认整个菜单的祖先链上有没有这些属性把transform动画移到别处或者干脆让菜单容器脱离那个父级直接用position: fixed配合 JS 计算坐标。这个坑在后台模板里最常见因为侧边栏常带transform过渡。5.4 菜单文字前出现不明符号伪元素content在背锅现象菜单项前面多了个圆圈、斜杠或诡异字符样式表里根本没有写过。原因这类效果大多是某个伪元素在背锅比如源码用.menu-item::before { content: ›; }做“更多”指示符但选择器没写限定结果所有菜单项都带一个箭头又或者字体图标没加载显示成了十六进制占位符。解决打开 DevTools 找到该伪元素的计算样式确认是哪条规则引入的。如果是content写死的符号直接删掉或改空字符串如果是字体图标给图标字体补一个系统字体回退.menu-item::before { content: none; }排查时把font-family也看一眼很多“花屏”是字体文件跨域加载失败和菜单源码本身无关。5.5 嵌进后台或iframe后菜单被裁剪弹出方向也是反的现象把菜单源码嵌进后台系统的侧边栏二级菜单被overflow: hidden裁掉半截或者向上弹出时超出视口被截断。原因后台布局为了滚动和圆角经常会给父容器设置overflow: hidden或overflow: auto绝对定位的子菜单一旦超出那个容器就会被裁剪。解决思路有两个一是把子菜单的定位从absolute改成fixed再用 JS 在展开时计算按钮在视口中的位置并设置 left/top适合全局悬浮的菜单二是给最近的父容器去掉overflow用overflow: visible但这要看布局允不允许。另外确认子菜单弹出方向视口底部空间不足时展开方向应该向上翻转参考 popper 类组件的 flip 行为。6. 把菜单从源码改造成接口驱动用数据换掉写死的HTML再验证一遍6.1 用fetch把一份菜单JSON渲染成真实菜单到了后台管理系统这一步菜单基本不能写死在页面里接口返回什么角色就显示什么菜单。改造方式就是第 4 章的递归渲染函数加一层 fetchfetch(/api/menu) .then(function (res) { return res.json(); }) .then(function (tree) { renderMenu(tree, document.querySelector(#nav)); }) .catch(function () { // 接口挂了回退到静态菜单别把导航页搞成白屏 renderMenu(defaultMenu, document.querySelector(#nav)); });接口返回的数组字段要和你渲染函数里的命名对上常见的是title、href、children。如果接口字段叫name或path在进入渲染函数前先做一层 map 转换别在渲染函数里堆判断。菜单模块搜索的逻辑也可以直接复用 3.3 节的过滤函数数据驱动后搜索范围变成整棵渲染好的树效果一致。6.2 键盘可达性与Esc关闭最小改动但别不做菜单源码默认只处理鼠标点击键盘用户 Tab 到隐藏的子菜单时可能发生焦点丢失。最小改动是补一个 Esc 关闭全局监听document.addEventListener(keydown, function (event) { if (event.key Escape) { document.querySelectorAll(.has-children[data-open]).forEach(function (li) { li.removeAttribute(data-open); }); } });style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />