CSS Scroll-Driven Animations:纯 CSS 实现滚动驱动动画
前言:滚动动画的前世今生
滚动驱动动画是 Web 上最常见的交互模式之一:页面顶部的阅读进度条、滚动时元素渐入、视差滚动效果……这些效果以往全部依赖 JavaScript,通常是监听 scroll 事件配合 requestAnimationFrame,或者使用 GSAP ScrollTrigger、Intersection Observer 等库。
// 传统方式:JS 监听 scrollwindow.addEventListener('scroll', () => { const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight); progressBar.style.width = `${progress * 100}%`;});这种方式有几个问题:
- 性能:
scroll事件在主线程触发,频率极高,容易造成卡顿 - 复杂度:需要手动计算进度、处理边界情况、管理生命周期
- 不可组合:很难将滚动动画与 CSS 动画系统结合
CSS Scroll-Driven Animations 彻底改变了这一局面。它将滚动进度与 CSS 动画的时间线绑定,让浏览器在合成器线程(compositor thread)上高效执行动画,完全不阻塞主线程。
核心概念:Animation Timeline
传统 CSS 动画由时间驱动——动画从开始到结束经过固定时长:
.box { animation: fade-in 1s ease-out; /* 1秒内完成 */}Scroll-Driven Animations 引入了新的时间线类型——动画由滚动进度驱动:
.box { animation: fade-in linear; animation-timeline: scroll(); /* 由滚动进度驱动 */}这里不需要 animation-duration,因为动画的”时间”就是滚动进度(0% 到 100%)。
CSS Scroll-Driven Animations 提供了两种时间线:
| 时间线类型 | 函数 | 含义 |
|---|---|---|
| Scroll Progress Timeline | scroll() | 基于滚动容器的整体滚动进度 |
| View Progress Timeline | view() | 基于元素在滚动口中的可见进度 |
scroll():滚动进度时间线
基本语法
.element { animation: my-animation linear; animation-timeline: scroll();}scroll() 函数接受两个可选参数:
scroll(<scroller>, <axis>)- scroller:指定哪个滚动容器
nearest(默认):最近的可滚动祖先root:文档根滚动容器(通常是 viewport)self:元素自身(如果它是可滚动的)
- axis:指定哪个轴的滚动
block(默认):块轴(通常是垂直方向)inline:行轴(通常是水平方向)y:垂直轴x:水平轴
/* 基于根滚动容器的垂直滚动 */.element { animation-timeline: scroll(root block);}
/* 基于最近可滚动祖先的水平滚动 */.element { animation-timeline: scroll(nearest inline);}
/* 基于自身的垂直滚动 */.scrollable-box { animation-timeline: scroll(self);}实战:阅读进度条
这是最经典的滚动动画用例——页面顶部的阅读进度指示条:
<div class="progress-bar"></div><article> <h1>很长的文章标题</h1> <p>很长的文章内容...</p> <!-- 很多内容 --></article>.progress-bar { position: fixed; top: 0; left: 0; height: 4px; background: linear-gradient(90deg, #3b82f6, #8b5cf6); transform-origin: left; z-index: 1000;
/* 动画:从 scaleX(0) 到 scaleX(1) */ animation: grow-progress linear; animation-timeline: scroll(root);}
@keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); }}就这么简单。 不需要一行 JavaScript,进度条就能随页面滚动平滑增长。而且由于 transform 动画在合成器线程执行,性能极佳——60fps 毫无压力。
实战:滚动驱动的导航栏变化
.navbar { position: fixed; top: 0; width: 100%; z-index: 100;
animation: navbar-shrink linear; animation-timeline: scroll(root);
/* 只在滚动了一小段后触发 */ animation-range: 0px 200px;}
@keyframes navbar-shrink { from { padding: 1.5rem 2rem; background: transparent; backdrop-filter: blur(0); box-shadow: none; } to { padding: 0.75rem 2rem; background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(12px); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }}
/* logo 也跟着缩小 */.navbar-logo { animation: logo-shrink linear; animation-timeline: scroll(root); animation-range: 0px 200px;}
@keyframes logo-shrink { from { height: 48px; } to { height: 32px; }}animation-range:控制动画范围
animation-range 让你指定动画在滚动的哪个区间内执行:
/* 在滚动 0px 到 500px 的范围内执行动画 */.element { animation: my-anim linear; animation-timeline: scroll(root); animation-range: 0px 500px;}
/* 使用百分比 */.element { animation-range: 0% 50%; /* 前半段滚动 */}
/* 拆开写 */.element { animation-range-start: 100px; animation-range-end: 500px;}view():视图进度时间线
view() 是另一种时间线,它跟踪元素在滚动口中的可见性。当元素从一侧进入视口、穿过视口、从另一侧离开时,进度从 0% 变化到 100%。
基本语法
view(<axis>, <inset>)- axis:与
scroll()相同,默认block - inset:调整滚动口的边界(类似
rootMargin)
.card { animation: fade-slide-in linear both; animation-timeline: view();}
@keyframes fade-slide-in { from { opacity: 0; transform: translateY(50px); } to { opacity: 1; transform: translateY(0); }}view() 的进度阶段
view() 时间线有几个关键阶段,可以通过 animation-range 精确控制:
| 范围名称 | 含义 |
|---|---|
cover | 元素从开始进入到完全离开的整个范围(默认) |
contain | 元素完全在视口内的范围 |
entry | 元素从开始进入到完全进入视口的范围 |
exit | 元素从开始离开到完全离开视口的范围 |
entry-crossing | 元素与视口入口边缘交叉的范围 |
exit-crossing | 元素与视口出口边缘交叉的范围 |
/* 只在元素进入视口时播放动画 */.card { animation: slide-in linear both; animation-timeline: view(); animation-range: entry 0% entry 100%;}
/* 简写 */.card { animation-range: entry;}
/* 在进入的后半段开始,到完全可见时结束 */.card { animation-range: entry 50% contain 0%;}实战:元素入场动画
<section class="content"> <div class="card animate-on-scroll"> <h2>特性一</h2> <p>描述内容...</p> </div> <div class="card animate-on-scroll"> <h2>特性二</h2> <p>描述内容...</p> </div> <div class="card animate-on-scroll"> <h2>特性三</h2> <p>描述内容...</p> </div></section>.animate-on-scroll { animation: reveal linear both; animation-timeline: view(); animation-range: entry 10% entry 90%;}
@keyframes reveal { from { opacity: 0; transform: translateY(60px) scale(0.95); filter: blur(4px); } to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }}不同于 Intersection Observer 的”一次性触发”,这个动画是连续的——元素在进入视口的过程中逐渐显现,如果你向上滚动,它还会反向播放(逐渐消失)。
实战:带 stagger 效果的入场
.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem;}
.card-grid .card { animation: stagger-in linear both; animation-timeline: view(); animation-range: entry;}
@keyframes stagger-in { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); }}
/* 利用 animation-delay 实现 stagger(使用负延迟不影响时间线) */.card-grid .card:nth-child(1) { animation-delay: 0s; }.card-grid .card:nth-child(2) { animation-delay: -0.1s; }.card-grid .card:nth-child(3) { animation-delay: -0.2s; }注意:在 scroll-driven 动画中,
animation-delay的行为有所不同。你可能需要用animation-range来实现真正的错开效果。
视差滚动效果
视差(Parallax)是滚动动画中最经典的效果——不同层的元素以不同速度滚动,创造深度感。
<div class="parallax-section"> <div class="parallax-bg"></div> <div class="parallax-content"> <h1>探索未知</h1> <p>向下滚动开始旅程</p> </div></div>.parallax-section { position: relative; height: 100vh; overflow: hidden;}
.parallax-bg { position: absolute; inset: -20% 0; background: url('mountain.jpg') center / cover;
/* 背景图以更慢的速度移动 */ animation: parallax-scroll linear; animation-timeline: scroll(root);}
@keyframes parallax-scroll { from { transform: translateY(-10%); } to { transform: translateY(10%); }}
.parallax-content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: white; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);}多层视差
.layer-far { animation: parallax-far linear; animation-timeline: scroll(root);}
.layer-mid { animation: parallax-mid linear; animation-timeline: scroll(root);}
.layer-near { animation: parallax-near linear; animation-timeline: scroll(root);}
/* 越远的层移动越慢 */@keyframes parallax-far { from { transform: translateY(0); } to { transform: translateY(100px); }}
@keyframes parallax-mid { from { transform: translateY(0); } to { transform: translateY(200px); }}
@keyframes parallax-near { from { transform: translateY(0); } to { transform: translateY(350px); }}命名时间线:timeline-scope 与 scroll-timeline-name
当动画元素不是滚动容器的后代时,需要使用命名时间线:
/* 定义命名的滚动时间线 */.scroller { overflow-y: scroll; scroll-timeline-name: --my-scroller; scroll-timeline-axis: block; /* 简写 */ scroll-timeline: --my-scroller block;}
/* 在祖先元素上声明 timeline-scope,让时间线可以跨子树共享 */.layout { timeline-scope: --my-scroller;}
/* 使用命名时间线 */.progress-indicator { animation: grow linear; animation-timeline: --my-scroller;}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); }}类似地,view-timeline-name 可以创建命名的视图时间线:
.hero-section { view-timeline-name: --hero; view-timeline-axis: block; /* 简写 */ view-timeline: --hero block;}
.header { animation: header-style linear both; animation-timeline: --hero; animation-range: exit;}
@keyframes header-style { from { background: transparent; } to { background: white; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }}综合实战:完整的滚动叙事页面
下面是一个综合运用各种技术的完整示例——一个滚动叙事(scrollytelling)页面:
<body> <!-- 进度条 --> <div class="scroll-progress"></div>
<!-- 导航 --> <nav class="nav"> <div class="nav-logo">BRAND</div> <div class="nav-links"> <a href="#intro">介绍</a> <a href="#features">特性</a> <a href="#pricing">价格</a> </div> </nav>
<!-- 英雄区域 --> <section class="hero" id="intro"> <h1 class="hero-title">构建未来</h1> <p class="hero-subtitle">下一代 Web 体验</p> <div class="hero-graphic"></div> </section>
<!-- 特性展示 --> <section class="features" id="features"> <div class="feature-card"> <div class="feature-icon">⚡</div> <h2>极速</h2> <p>毫秒级响应,流畅如丝</p> </div> <div class="feature-card"> <div class="feature-icon">🔒</div> <h2>安全</h2> <p>企业级安全防护</p> </div> <div class="feature-card"> <div class="feature-icon">🎨</div> <h2>美观</h2> <p>精心设计每一个像素</p> </div> </section>
<!-- 更多内容... --></body>/* ===== 进度条 ===== */.scroll-progress { position: fixed; top: 0; left: 0; right: 0; height: 3px; background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899); transform-origin: left; z-index: 9999;
animation: progress-grow linear; animation-timeline: scroll(root);}
@keyframes progress-grow { from { transform: scaleX(0); } to { transform: scaleX(1); }}
/* ===== 导航栏滚动效果 ===== */.nav { position: fixed; top: 0; width: 100%; padding: 1.5rem 2rem; display: flex; justify-content: space-between; align-items: center; z-index: 100; transition: padding 0.3s;
animation: nav-compact linear both; animation-timeline: scroll(root); animation-range: 0px 300px;}
@keyframes nav-compact { from { padding: 1.5rem 2rem; background: transparent; } to { padding: 0.75rem 2rem; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(16px); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); }}
/* ===== 英雄区域视差 ===== */.hero { height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; position: relative; overflow: hidden;}
.hero-title { font-size: clamp(3rem, 8vw, 8rem); font-weight: 900;
animation: hero-text-parallax linear; animation-timeline: scroll(root); animation-range: 0vh 100vh;}
@keyframes hero-text-parallax { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-150px); opacity: 0; }}
.hero-subtitle { font-size: 1.5rem; color: #6b7280;
animation: hero-sub-parallax linear; animation-timeline: scroll(root); animation-range: 0vh 80vh;}
@keyframes hero-sub-parallax { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-100px); opacity: 0; }}
.hero-graphic { position: absolute; width: 500px; height: 500px; border-radius: 50%; background: radial-gradient(circle, #dbeafe, #bfdbfe);
animation: hero-graphic-scale linear; animation-timeline: scroll(root); animation-range: 0vh 100vh;}
@keyframes hero-graphic-scale { from { transform: scale(1); opacity: 0.3; } to { transform: scale(3); opacity: 0; }}
/* ===== 特性卡片入场 ===== */.feature-card { padding: 2rem; border-radius: 16px; background: white; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
animation: card-enter linear both; animation-timeline: view(); animation-range: entry 0% entry 100%;}
@keyframes card-enter { from { opacity: 0; transform: translateY(60px) scale(0.9); } 50% { opacity: 1; } to { opacity: 1; transform: translateY(0) scale(1); }}
/* 图标旋转入场 */.feature-icon { font-size: 3rem; display: inline-block;
animation: icon-spin linear both; animation-timeline: view(); animation-range: entry 20% entry 80%;}
@keyframes icon-spin { from { transform: rotate(-180deg) scale(0); opacity: 0; } to { transform: rotate(0) scale(1); opacity: 1; }}
/* ===== 尊重用户偏好 ===== */@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; }}JavaScript 回退方案
对于不支持 CSS Scroll-Driven Animations 的浏览器,提供优雅的回退:
/* 默认状态:不支持时元素正常显示 */.feature-card { opacity: 1; transform: none;}
/* 只在支持时应用滚动动画 */@supports (animation-timeline: scroll()) { .feature-card { animation: card-enter linear both; animation-timeline: view(); animation-range: entry; }}或者用 Intersection Observer 作为 JS 回退:
// 特性检测if (!CSS.supports('animation-timeline', 'scroll()')) { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add('is-visible'); observer.unobserve(entry.target); } }); }, { threshold: 0.1 } );
document.querySelectorAll('.animate-on-scroll').forEach((el) => { observer.observe(el); });}/* JS 回退样式 */.animate-on-scroll { opacity: 0; transform: translateY(40px); transition: opacity 0.6s ease, transform 0.6s ease;}
.animate-on-scroll.is-visible { opacity: 1; transform: none;}浏览器兼容性
截至 2026 年初:
| 特性 | Chrome | Firefox | Safari |
|---|---|---|---|
scroll() | 115+ ✅ | 🟡 Flag | ❌ |
view() | 115+ ✅ | 🟡 Flag | ❌ |
animation-range | 115+ ✅ | 🟡 Flag | ❌ |
| 命名时间线 | 115+ ✅ | 🟡 Flag | ❌ |
目前 Chrome(和 Edge)支持最好,Firefox 在 flag 后面支持,Safari 暂不支持。建议:
- 使用
@supports做渐进增强 - 提供 JS 回退方案(Intersection Observer)
- 确保不支持时内容仍然可用
性能优势
CSS Scroll-Driven Animations 的最大优势之一是性能。与 JS 方案对比:
| 方面 | JS scroll 事件 | CSS Scroll-Driven |
|---|---|---|
| 执行线程 | 主线程 | 合成器线程(compositor) |
| 帧率 | 依赖主线程负载 | 稳定 60fps+ |
| 动画属性 | 任意 | transform, opacity 等合成属性最佳 |
| 内存开销 | 需要 JS 运行时 | 极低 |
| 电池消耗 | 较高 | 较低 |
关键点:只要你的动画只涉及 transform 和 opacity(合成属性),浏览器可以完全在 GPU 上执行,不触及主线程。这意味着即使主线程繁忙(大量 JS 执行),动画依然丝滑。
总结
CSS Scroll-Driven Animations 代表了 Web 动画的未来方向——声明式、高性能、无 JavaScript。
核心要点回顾:
scroll():基于滚动容器的整体滚动进度驱动动画view():基于元素在视口中的可见性驱动动画animation-range:精确控制动画在哪个滚动/可见范围内执行- 命名时间线:让动画可以引用非祖先的滚动容器
- 性能卓越:在合成器线程执行,不阻塞主线程
虽然目前浏览器支持还在推进中,但在支持的浏览器中使用它作为渐进增强,可以极大地提升用户体验和开发效率。随着 Firefox 和 Safari 的跟进,它将成为 Web 动画的标准方案。
开始实践:从最简单的阅读进度条开始,然后尝试元素入场动画,最后挑战视差效果和复杂的滚动叙事页面。你会发现,以前需要几十行 JavaScript 的效果,现在几行 CSS 就能搞定。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!