简介:这是一份面向前端初学者与进阶练习者的滚动渐变导航栏实战小实例,围绕HTML5、CSS3与JavaScript的协同使用展开,帮助读者理解页面滚动时导航栏背景、透明度与视觉样式的动态过渡效果,适合用于个人作品集、企业官网或课程作业中的顶部导航模块改造。资源包共5个文件,包含1个html页面、1个css样式表、2张jpg图片素材与1个gif动效演示,压缩包为7z格式,整体约1.48MB,体积轻量,便于快速解压查看与本地调试。其中html负责结构搭建,css承担渐变与过渡样式,图片与动效文件则用于还原真实视觉场景。目前已有156人学习,说明该案例在基础交互练习中具有一定参考价值。读者可从中获取完整的页面结构、样式组织方式与滚动监听实现思路,并借助动效演示对照调试,快速掌握滚动渐变导航栏的核心做法。
1. 滚动渐变导航栏:一个被低估的交互细节,藏着多少实现路径
页面往下滚,导航栏从透明慢慢变成实色,同时高度收缩、阴影浮现——这个效果几乎每个现代网站都在用,但真正自己从零写一遍的人不多。很多人第一反应是找个插件,或者直接复制一段网上搜来的代码,结果发现滚动时导航栏闪烁、移动端卡顿、阈值判断玄学。这个 HTML5+CSS3+JS 小实例要解决的就是这件事:不依赖任何第三方库,用原生三件套实现一个滚动渐变导航栏,代码量控制在百行以内,适合刚接触 DOM 事件和 CSS 过渡的开发者拿来练手,也适合需要快速落地一个轻量导航组件的前端从业者直接取用。它不炫技,但把滚动监听、类名切换、CSS 过渡这三件事的配合关系讲清楚了,后面你再遇到类似“滚动触发状态变化”的需求,基本是同一套骨架。
2. 滚动渐变的底层逻辑:scrollY、阈值与类名切换
2.1 为什么不用 scroll 事件里直接改 style
先想清楚一件事:滚动渐变导航栏的本质是什么。它不是“滚动时改变导航栏样式”,而是“滚动位置越过某个阈值时,给导航栏切换一个状态类”。这两句话听起来差不多,但实现思路完全不同。
前者的做法是在scroll事件回调里直接写nav.style.backgroundColor = '...'、nav.style.height = '...',每滚一像素就改一次。这种写法的问题在于:scroll事件触发频率极高,尤其在移动端,每帧可能触发多次,直接操作style会强制浏览器反复计算样式和重绘,滚动一卡一卡的,血泪经验就是从这里来的。
后者的做法是:CSS 里定义好两个状态的样式,JS 只负责在阈值前后切换一个类名。浏览器对类名切换的优化远好于逐条改内联样式,因为 CSS 过渡可以交给合成层处理,background-color和box-shadow的过渡不会触发布局重排,height的过渡虽然会触发布局,但只发生一次,代价可控。
所以核心思路定下来:CSS 管视觉过渡,JS 管状态判断。
2.2 阈值判断的三种写法与选择
阈值判断就是“滚动多少像素之后算滚下去了”。常见三种写法:
第一种,固定像素值,比如window.scrollY > 80。简单直接,适合导航栏高度固定的场景。缺点是如果导航栏本身高度变了,这个值要跟着调。
第二种,用导航栏自身高度作为阈值,window.scrollY > nav.offsetHeight。这样导航栏多高,滚过它自身高度就触发,逻辑上更自洽。但注意offsetHeight在导航栏高度有过渡动画时会变,如果阈值也动态取这个值,可能在过渡过程中反复触发,需要加锁。
第三种,用IntersectionObserver观察页面顶部一个哨兵元素。这是更现代的做法,不监听scroll事件,性能最好,但代码量稍多,且对老浏览器兼容性一般。
这个实例里我一般用第二种的变体:阈值取一个固定值,但把这个值定义成 CSS 变量或 JS 常量,方便统一调整。原因是导航栏高度在这个实例里是固定的,没必要引入动态计算的复杂度。如果你要做的是高度可变的导航栏,再考虑第一种或第三种。
2.3 最小可用代码骨架
先把 HTML 结构搭出来。导航栏固定在顶部,页面内容足够长以产生滚动。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>滚动渐变导航栏</title> <link rel="stylesheet" href="style.css"> </head> <body> <!-- 导航栏:初始透明,滚动后加 .scrolled 类 --> <nav class="navbar" id="navbar"> <div class="nav-inner"> <a href="#" class="logo">BRAND</a> <ul class="nav-links"> <li><a href="#">首页</a></li> <li><a href="#">产品</a></li> <li><a href="#">关于</a></li> </ul> </div> </nav> <!-- 首屏区域:给导航栏一个透明背景的展示空间 --> <header class="hero"> <h1>滚动看看效果</h1> </header> <!-- 长内容区域:制造滚动空间 --> <main class="content"> <p>往下滚动,导航栏会渐变。</p> <!-- 重复若干段以撑开页面 --> </main> <script src="script.js"></script> </body> </html>这段结构里有两个关键点:nav上挂了id="navbar"供 JS 获取;hero区域高度要足够,让导航栏初始透明状态有展示空间。content区域负责撑出滚动条,实际使用时替换成真实内容即可。
CSS 部分定义两个状态的样式,用transition做过渡:
/* 基础重置 */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; /* 关键:给 body 设一个背景色,否则导航栏透明时透出的是白色, 渐变效果不明显 */ background: #f5f5f5; } /* 导航栏初始状态:透明背景、无阴影、较高 */ .navbar { position: fixed; top: 0; left: 0; width: 100%; height: 80px; background-color: transparent; box-shadow: none; /* 过渡属性:背景色、高度、阴影,时长 0.3s */ transition: background-color 0.3s ease, height 0.3s ease, box-shadow 0.3s ease; z-index: 1000; } /* 滚动后状态:白色背景、有阴影、高度收缩 */ .navbar.scrolled { background-color: #ffffff; height: 56px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } /* 导航栏内部布局 */ .nav-inner { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; } .logo { font-size: 20px; font-weight: 700; color: #333; text-decoration: none; } .nav-links { display: flex; gap: 24px; list-style: none; } .nav-links a { color: #333; text-decoration: none; font-size: 15px; } /* 首屏区域:高度 100vh,背景用渐变,让透明导航栏可见 */ .hero { height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; } /* 内容区域:撑开滚动 */ .content { max-width: 800px; margin: 0 auto; padding: 60px 24px; line-height: 1.8; color: #444; }CSS 里几个参数值得说明。transition的三个属性分别对应背景色、高度、阴影,时长统一 0.3s,缓动函数用ease。如果你想让高度变化更跟手,可以把height的过渡时长单独设短一点,比如 0.2s,但一般没必要。z-index: 1000保证导航栏盖在内容之上。.navbar.scrolled里的height: 56px是收缩后的高度,和初始的 80px 形成对比,视觉上导航栏“变薄”了。
JS 部分只做一件事:监听滚动,判断阈值,切换类名。
// 获取导航栏元素 const navbar = document.getElementById('navbar'); // 阈值:滚动超过 80px 时切换状态 // 这个值建议等于或略小于导航栏初始高度 const SCROLL_THRESHOLD = 80; // 用变量记录当前状态,避免重复操作 DOM let isScrolled = false; function handleScroll() { // window.scrollY 是当前垂直滚动距离 const shouldBeScrolled = window.scrollY > SCROLL_THRESHOLD; // 只有状态变化时才操作 DOM,减少无谓的类名切换 if (shouldBeScrolled !== isScrolled) { isScrolled = shouldBeScrolled; navbar.classList.toggle('scrolled', isScrolled); } } // 监听滚动事件 // passive: true 告诉浏览器这个监听器不会调用 preventDefault, // 浏览器可以立即滚动而不等待 JS 执行,移动端滚动更流畅 window.addEventListener('scroll', handleScroll, { passive: true }); // 页面加载时先执行一次,处理刷新后停留在中间位置的情况 handleScroll();这段 JS 的核心是isScrolled这个状态变量。如果不加它,每次scroll事件触发都会调用classList.toggle,虽然toggle本身是幂等的,但频繁调用仍有开销。加上状态变量后,只有跨越阈值的那一次才会真正操作 DOM。passive: true是移动端滚动的关键优化,不加的话浏览器要等 JS 执行完才滚动,手感会发涩。最后一行handleScroll()处理的是用户刷新页面时滚动位置不在顶部的情况,如果不调用,导航栏会保持透明直到用户再滚一下。
注意:
window.scrollY在部分老版本浏览器里是window.pageYOffset,如果需要兼容 IE,写成window.scrollY || window.pageYOffset。现代浏览器直接用scrollY即可。
3. 从能跑到好用:节流、requestAnimationFrame 与移动端适配
3.1 scroll 事件的性能陷阱与节流方案
上一章的代码已经能跑了,但如果你在低端安卓机上测试,快速滚动时可能会感觉到轻微掉帧。原因在于scroll事件的触发频率。桌面端 Chrome 大约每 16ms 触发一次,移动端可能更频繁。虽然我们的回调里只做了一次比较和一次classList.toggle,但事件本身从浏览器传到 JS 层就有开销。
常见的优化手段是节流。节流不是“降低滚动灵敏度”,而是“限制回调执行频率”。对于这个场景,阈值判断本身不需要每帧都做,每 100ms 检查一次完全够用。
// 节流函数:限制 fn 在 wait 毫秒内最多执行一次 function throttle(fn, wait) { let lastTime = 0; return function (...args) { const now = Date.now(); if (now - lastTime >= wait) { lastTime = now; fn.apply(this, args); } }; } // 用节流包装滚动处理函数,100ms 检查一次 const throttledScroll = throttle(handleScroll, 100); window.addEventListener('scroll', throttledScroll, { passive: true });节流的参数wait怎么定?100ms 是经验值,对应每秒最多 10 次检查。如果你觉得导航栏状态切换有延迟感,可以降到 50ms;如果追求极致性能,可以升到 200ms,但超过 200ms 用户会感觉到“慢半拍”。我一般用 100ms,兼顾手感和性能。
3.2 requestAnimationFrame 的替代思路
节流之外,另一种常见做法是用requestAnimationFrame把滚动处理对齐到浏览器渲染帧。思路是:scroll事件里只标记“需要更新”,真正的更新逻辑放在rAF回调里执行。
let ticking = false; function onScroll() { if (!ticking) { // 请求下一帧执行更新 window.requestAnimationFrame(() => { handleScroll(); ticking = false; }); ticking = true; } } window.addEventListener('scroll', onScroll, { passive: true });这种写法的好处是更新时机和浏览器渲染同步,不会出现“JS 执行了但浏览器还没渲染”的撕裂感。缺点是代码多了一层ticking标志,理解成本稍高。对于这个实例,节流方案已经足够,rAF方案更适合滚动过程中有复杂动画的场景,比如视差滚动。
3.3 移动端 100vh 与滚动阈值偏移
移动端有一个经典坑:100vh在地址栏收起/展开时会变化,导致首屏区域高度跳动,进而影响滚动阈值的判断。如果你的hero区域用了100vh,在移动端滚动时,地址栏收起会让100vh变大,页面内容整体下移,scrollY的读数也会跳变。
解决办法是用100dvh(动态视口高度)替代100vh,或者用 JS 动态设置hero高度为window.innerHeight。dvh单位在现代移动端浏览器支持已经不错,可以直接用:
.hero { height: 100dvh; /* 动态视口高度,随地址栏变化 */ }如果必须兼容老浏览器,就在 JS 里监听resize事件,把hero高度设为window.innerHeight + 'px'。但注意resize在移动端滚动时也可能触发,需要配合节流。
另一个移动端适配点是阈值偏移。桌面端滚动 80px 触发,移动端因为导航栏可能更矮,80px 可能显得触发太晚。常见做法是根据视口宽度动态调整阈值:
const SCROLL_THRESHOLD = window.innerWidth < 768 ? 50 : 80;这个值在页面加载时确定一次即可,不需要在resize时重新计算,因为导航栏高度通常不随窗口宽度变化。如果你的导航栏在移动端有不同高度,再考虑监听resize。
3.4 用 CSS 变量统一管理阈值与过渡参数
当项目里多个地方用到同一个阈值或过渡时长时,硬编码的数字会变成维护噩梦。用 CSS 变量把参数抽出来:
:root { --nav-height: 80px; --nav-height-scrolled: 56px; --nav-transition: 0.3s ease; } .navbar { height: var(--nav-height); transition: background-color var(--nav-transition), height var(--nav-transition), box-shadow var(--nav-transition); } .navbar.scrolled { height: var(--nav-height-scrolled); }JS 里读取这个变量:
const rootStyles = getComputedStyle(document.documentElement); const navHeight = parseInt(rootStyles.getPropertyValue('--nav-height')); const SCROLL_THRESHOLD = navHeight; // 阈值等于导航栏初始高度这样改导航栏高度时只需要改一处 CSS 变量,JS 阈值自动跟着变。getComputedStyle返回的是字符串,用parseInt转成数字。注意--nav-height的值带px单位,parseInt会忽略单位只取数字部分,正好符合需求。
4. 避坑与排查:滚动渐变导航栏最常见的五个翻车现场
4.1 滚动时导航栏闪烁或抖动
现象:滚动过程中导航栏背景色或高度反复跳变,像在“抽搐”。
原因:阈值判断没有加状态锁,或者阈值设置在了过渡动画的中间值附近。比如导航栏高度从 80px 过渡到 56px,如果阈值是 70px,滚动位置在 70px 附近微调时,scrollY > 70会反复为真为假,类名反复切换。
解决:确保阈值不等于过渡中的高度值。阈值应该取初始高度(80px)或略小,且切换后导航栏高度变化不影响阈值判断。另外检查isScrolled状态变量是否正确阻止了重复切换。如果用了节流,节流的wait不要设得太大,否则状态切换有延迟,用户快速滚动时可能看到“该变没变”的滞后感。
4.2 页面刷新后导航栏状态不对
现象:用户滚动到页面中间,按 F5 刷新,导航栏保持透明,但页面内容已经在中间位置。
原因:浏览器刷新后会恢复滚动位置,但scroll事件不会自动触发,JS 里的handleScroll没有被调用。
解决:在 JS 末尾手动调用一次handleScroll(),如第 2 章代码所示。更稳妥的做法是监听DOMContentLoaded或load事件后再调用,确保 DOM 已经就绪:
window.addEventListener('load', handleScroll);4.3 移动端滚动卡顿
现象:桌面端流畅,移动端滚动时导航栏过渡动画掉帧。
原因:scroll监听器没有加passive: true,浏览器需要等 JS 执行完才能滚动;或者过渡属性里包含了height这种触发布局重排的属性,移动端 GPU 性能弱,重排代价高。
解决:第一,addEventListener第三个参数加{ passive: true }。第二,如果卡顿依然明显,考虑用transform: scaleY()替代height过渡,transform不触发布局重排,只触发合成,性能更好。但scaleY会拉伸内部文字,需要配合反向缩放,代码复杂度上升。对于导航栏这种小面积元素,height过渡通常可以接受,先加passive试试。
4.4 导航栏透明时文字看不清
现象:首屏背景是浅色图片或浅色渐变,导航栏透明状态下文字也是浅色,导致文字和背景融为一体。
原因:导航栏文字颜色没有根据背景做适配。
解决:两个方案。方案一,首屏背景用深色,导航栏文字用白色,滚动后导航栏变白,文字变深色。这需要在.navbar.scrolled里同时改文字颜色:
.navbar .nav-links a { color: #fff; transition: color 0.3s ease; } .navbar.scrolled .nav-links a { color: #333; }方案二,给导航栏文字加text-shadow,让它在任何背景下都有可读性。但text-shadow在白色背景上会显得脏,所以还是方案一更干净。
4.5 阈值判断在缩放或横屏时失效
现象:用户缩放页面或手机横屏后,滚动阈值感觉不对,导航栏切换时机偏移。
原因:阈值是固定像素值,但缩放后视口尺寸变了,导航栏的实际视觉高度可能变化。
解决:如果导航栏高度用了rem或vw单位,阈值也应该用相对单位。但更简单的做法是:阈值始终基于导航栏的offsetHeight动态计算,并在resize时重新计算:
let SCROLL_THRESHOLD = navbar.offsetHeight; window.addEventListener('resize', () => { SCROLL_THRESHOLD = navbar.offsetHeight; handleScroll(); // 重新判断当前状态 });注意resize也要节流,否则旋转屏幕时可能触发多次。另外,如果导航栏在滚动后高度变了,offsetHeight读的是当前高度,所以要在导航栏处于初始状态时读取,或者用一个固定的初始高度常量。
5. 进阶技巧:用 IntersectionObserver 替代 scroll 监听
前面几章都是围绕scroll事件展开的,但如果你追求更干净的性能表现,IntersectionObserver是更现代的选择。它的思路是:在页面顶部放一个哨兵元素,观察它是否离开视口,离开就说明滚下去了。
// 在导航栏后面放一个哨兵元素,高度等于阈值 // <div class="scroll-sentinel"></div> const sentinel = document.querySelector('.scroll-sentinel'); const observer = new IntersectionObserver( ([entry]) => { // entry.isIntersecting 为 false 表示哨兵离开视口,即滚过阈值 navbar.classList.toggle('scrolled', !entry.isIntersecting); }, { // rootMargin 可以微调触发时机 // 这里设为 0,哨兵完全离开视口时触发 rootMargin: '0px', threshold: 0 } ); observer.observe(sentinel);哨兵元素的 CSS:
.scroll-sentinel { position: absolute; top: 0; left: 0; width: 1px; height: 80px; /* 等于阈值 */ pointer-events: none; /* 不影响点击 */ visibility: hidden; /* 不可见但占位 */ }IntersectionObserver的优势很明显:不监听scroll事件,回调由浏览器在合适的时机触发,性能开销极低;不需要节流,不需要passive,不需要状态锁。缺点是兼容性——IE 完全不支持,部分老版本移动端浏览器也不支持。如果你的项目需要兼容 IE,还是用scroll方案;如果只面向现代浏览器,IntersectionObserver是更优雅的选择。
rootMargin这个参数值得单独说。它允许你扩展或收缩观察区域的边界。比如设成'0px 0px -20px 0px',表示观察区域底部向上收缩 20px,哨兵要再往上滚 20px 才触发。这个可以用来微调触发时机,让导航栏切换稍微“提前”或“延后”。我一般用'0px',保持和阈值严格对应。
还有一个细节:IntersectionObserver的回调在页面加载时也会触发一次,所以不需要像scroll方案那样手动调用handleScroll()。但如果你在回调里做了依赖 DOM 的操作,确保 DOM 已经就绪。
从那以后我每次做滚动相关的交互,都强制走一遍这个检查清单:先确认用scroll还是IntersectionObserver,再确认阈值和过渡参数是否统一管理,最后在移动端真机上滚一遍。这三步走完,基本不会再翻车。希望帮到你。
本文还有配套的精品资源,点击获取