上个月公司官网改版,设计师拿过来一个长页面,要求滚动的时候有进度条指示、图片滑入、背景色渐变切换,甚至某个区域要有一点视差味道。我下意识打开npm找动画库,结果被一个前端同事拦住了:”现在浏览器原生就能干这事,你试试scroll-timeline,别再加依赖了。”
我一开始是拒绝的——总觉得这种新特性还不稳。但看完MDN的兼容性表又实际测了一遍,Chrome 115+、Edge 115+都已经支持,Firefox也在跟进,移动端也有相当可观的覆盖率。最关键的是,它写起来比我想象中简单太多,而且不需要任何polyfill就能在主流浏览器上跑。这篇文章就把我这一个月在项目里实战的几个场景整理出来,附上完整代码和你可能会遇到的坑。
一、传统JS做滚动动画到底烦在哪里
在接触scroll-timeline之前,我写滚动驱动的效果基本是这个套路:
- 监听
window.scroll事件,拿到scrollY - 算一堆百分比,再用
requestAnimationFrame去更新DOM - 页面复杂的时候还得加
IntersectionObserver来优化性能 - 组件卸载时要手动解绑事件,不然内存泄漏
听起来就是那种”明明需求很简单,但实现起来要处理一堆边界条件”的典型场景。尤其是当页面里有多个不同的滚动区域(比如某个
核心优势就两个:性能更好(因为动画运行在合成器线程,不占用主线程),代码量至少砍掉70%。
二、先搞懂两条线:scroll进度和view进度
CSS滚动驱动动画目前有两个主要的功能函数——scroll()和view()。它们分别对应两种不同的参照系。
2.1 scroll() —— 基于滚动容器的进度
简单理解就是:某个滚动条从顶部滚到底部,这个过程的百分比。你可以把它绑定到animation-timeline属性上,让动画的播放进度跟着滚动进度走。
/* 假设页面的滚动容器是根元素 */
@keyframes growWidth {
from { width: 0%; }
to { width: 100%; }
}
.progress-bar {
animation: growWidth linear;
animation-timeline: scroll(root);
/* 上面这行的意思是:用根元素的滚动进度来驱动growWidth动画 */
}
scroll(root)里的root代表根滚动容器(就是document.documentElement的滚动条)。你也可以指定其他带有overflow: scroll的元素,但那个元素必须是一个实际的滚动容器,且它在DOM层级上应该是动画元素的祖先(这一点后面会详细说,我在这里踩过坑)。
2.2 view() —— 基于元素与视口的关系
这个更贴近我们常说的”元素进入视口”场景。view()跟踪的是一个元素从开始进入滚动容器的可视区域,到完全离开这个区域的过程。比如一个图片从视口底部冒出来,滑到中间,再从顶部离开——整个过程由view()捕获,并映射为0%到100%的进度。
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.article-image {
animation: fadeInUp 1s ease-out;
animation-timeline: view();
/* 图片进入视口时开始fadeInUp动画,离开视口时完成 */
}
view()还支持两个参数,用来控制动画的触发区间:view(block, start)。比如view(block 20% 80%)意味着元素顶部到达视口20%位置时动画开始,元素底部到达视口80%位置时动画结束。这个粒度的控制在我们做精细的叙事效果时特别有用。
三、实战案例一:顶部丝滑的阅读进度条
几乎每个长文章页面都会在顶部放一根进度条,随页面滚动慢慢变长。以前我一般用JS监听scroll然后计算百分比,现在几行CSS就能搞定。
3.1 HTML结构
<div class="reading-progress"></div>
<main class="article-content">
<!-- 这里放长文章内容,足够撑出滚动条 -->
<h1>文章标题</h1>
<p>...大量段落...</p>
</main>
3.2 关键CSS
/* 进度条容器固定在顶部 */
.reading-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: #e0e0e0;
z-index: 1000;
}
/* 进度条的填充部分,用伪元素实现 */
.reading-progress::before {
content: '';
display: block;
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
/* 动画:宽度从0到100% */
animation: progress-grow linear;
animation-timeline: scroll(root);
transform-origin: left center;
}
@keyframes progress-grow {
from { width: 0%; }
to { width: 100%; }
}
就是这么简单。没有事件监听,没有requestAnimationFrame回调,浏览器自己会根据页面滚动的实际像素精确映射动画进度。而且这个动画运行在合成器线程,即使在滚动过程中也保持丝滑。
有一点需要提醒:scroll(root)只能用于根滚动容器。如果你的滚动发生在某个内部的div里,比如一个设置了overflow-y: auto的侧边栏,那就需要把root换成那个滚动容器的具体引用。但目前CSS规范里引用命名滚动容器还比较受限,实际项目中如果必须追踪内部滚动,可能还是要用IntersectionObserver辅助,或者等待浏览器进一步完善支持。
四、实战案例二:图片的视口入场效果
文章详情页里经常有一堆配图,我们想让用户滚到图片附近时图片才从下方淡入滑上来,而不是一进页面就全部加载完动画。这个效果用view()实现最自然不过。
4.1 基本实现
/* 图片默认透明且下移,动画生效后恢复 */
.article-content img {
opacity: 0;
transform: translateY(40px);
animation: image-reveal 0.8s ease-out forwards;
animation-timeline: view();
/* 注意这里没有写animation-duration,因为时间线由滚动接管 */
animation-range: entry 0% cover 40%;
/* entry 0%:元素刚进入视口底部时动画开始
cover 40%:元素到达视口40%位置时动画结束 */
}
@keyframes image-reveal {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
这个animation-range属性是调节动画触发区间的关键。如果不写它,默认就是元素从进入视口底部到完全离开视口顶部,整个区间都是动画的播放范围。但通常我们希望图片在靠近视口中部的时候就完成动画,而不是快滚出屏幕了还在那儿慢悠悠地变,所以用cover 40%提前结束。
4.2 批量处理不同位置的图片
如果页面上有十张图片,每张都写一个@keyframes显然不现实。好在我们可以用相同的动画名称,然后通过animation-range和animation-delay做微调:
/* 所有图片共用同一套关键帧 */
.article-content img {
animation: image-reveal 0.6s ease-out forwards;
animation-timeline: view();
animation-range: entry 10% cover 35%;
}
/* 偶数图片稍微错开一点视觉节奏 */
.article-content img:nth-child(even) {
animation-range: entry 15% cover 40%;
}
实际效果就是:图片逐张进入视口时依次淡入上浮,节奏均匀但不死板。这种细微的差异在纯JS方案里通常需要分别计算每张图的getBoundingClientRect(),现在直接用CSS选择器批量处理,维护起来轻松很多。
五、实战案例三:卡片区域的联动背景渐变
这个需求稍微复杂一点。产品经理希望首页有一个”功能亮点”区域,里面有四张横向排列的卡片,随着这个区域从视口底部逐步进入并向上滚动,区域背景色从浅灰渐变成深蓝。说白了就是滚动位置控制背景色。
这里的关键在于:动画的进度不是映射到元素的width或opacity,而是映射到background-color。只要关键帧里写的是可动画的属性,scroll-timeline都能驱动。
5.1 区域结构
<section class="feature-section">
<div class="feature-section__inner">
<div class="feature-card">卡片1</div>
<div class="feature-card">卡片2</div>
<div class="feature-card">卡片3</div>
<div class="feature-card">卡片4</div>
</div>
</section>
5.2 背景渐变动画
.feature-section {
animation: bg-gradient-shift linear;
animation-timeline: view();
/* 整个区域从刚进入视口到完全离开视口,动画贯穿始终 */
animation-range: entry 0% exit 100%;
}
@keyframes bg-gradient-shift {
0% {
background-color: #f5f5f5;
}
100% {
background-color: #1e3a5f;
}
}
注意这里用了exit 100%,意思是元素底部到达视口顶部时才完成整个动画。如果你希望渐变在区域完全进入视口后就结束,可以改成animation-range: entry 0% contain 100%,这样当区域刚好完全处于视口内部时动画就100%了,后续再滚动不再变化。
实际测试中我发现一个小问题:如果这个区域后面紧跟着另一个很长的区域,用户可能会持续往下滚很远,但背景色已经不再变化了。为了让动画的”播放感”更强,我们在产品层面最终决定让动画在cover 80%的位置结束,这样背景色变化集中在前半段,用户感知更明显。这就是设计师说的”叙事节奏”,调了几个值最后定在80%。
六、你不能忽略的兼容性策略
我必须诚实地说:scroll-timeline和view-timeline这两兄弟目前还不是所有浏览器都支持。根据caniuse的数据,Chrome和Edge从115版本开始完整支持,Safari还在开发中,Firefox通过layout.css.scroll-driven-animations.enabled标志可以开启实验性支持。
但正因为如此,这个特性非常适合做渐进增强。不支持滚动驱动动画的浏览器会直接忽略animation-timeline属性,但animation本身还是会被正常解析——只不过动画会按照animation-duration在页面加载时立即播放一遍。
所以我的处理方式是:
- 对于进度条这种”没有也行”的效果,直接把
animation-duration设成一个极小值(比如0.001s),配合forwards填充模式,确保在不支持的浏览器上进度条始终是满的,不出现闪一下再消失的尴尬。 - 对于图片入场这类”没有就很奇怪”的效果,用
@supports查询做回退:
/* 只在支持scroll-timeline的浏览器中启用入场动画 */
@supports (animation-timeline: view()) {
.article-content img {
opacity: 0;
animation: image-reveal 0.6s ease-out forwards;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
/* 不支持的浏览器里图片默认就是可见的 */
.article-content img {
opacity: 1;
transform: none;
}
这样在不支持的浏览器中,图片依然是正常显示的,只是少了那个滑入效果。用户体验没有打折,而支持的浏览器则获得更好的视觉效果——标准的渐进增强思路。
七、我在项目中踩到的三个实在的坑
7.1 动画元素必须在滚动容器的后代中
这个坑浪费了我半天时间。我把进度条放在<body>的直接子级,然后用scroll(root)驱动,结果在Chrome里死活不生效。查文档才发现:scroll()跟踪的滚动容器必须是动画目标元素的祖先。但root(即html元素)和body之间有微妙的层级关系,在某些情况下body的直接子元素并不被认为是root的后代(这取决于overflow的设置)。
解决办法很简单:把需要滚动的进度条放在<body>内部足够深的位置,或者直接用position: fixed脱离文档流,这样层级关系就不会被混淆。后来我把进度条的HTML塞到<header>里,问题就消失了。
7.2 animation-range的百分比理解偏差
animation-range: entry 20% cover 80%这个写法里,百分比指的是滚动容器可视区域的位置,而不是元素自身的高度比例。我一开始以为是”元素自身20%的位置进入视口”,结果调出来的效果怎么都不对。正确理解是:entry 20%意味着元素的顶部触碰到视口20%高度位置时动画开始;cover 80%意味着元素的底部触碰到视口80%高度位置时动画结束。
这个细节在MDN上写了,但很容易被快速扫过。我建议初次使用的时候在浏览器DevTools里逐步调整数值,感受一下每个参数对动画触发时机的影响。
7.3 多个滚动驱动动画的性能叠加
理论上这些动画都运行在合成器线程,不会引起重排或重绘。但实际项目中如果一个页面上有几十个同时触发的view动画,还是能感觉到微小的帧率波动。尤其是在低端安卓设备上,我测试过20个以上并行的view()动画,偶尔会掉到50fps以下。
应对策略倒也简单:用animation-range把动画错开,不要让它们在同一时刻全部集中触发,同时给每个动画设定较短的持续时间(animation-duration设为0.4s到0.6s比较合适)。这样实际同时活动的动画数量就很少,帧率稳稳的。
八、几句实在的总结
CSS滚动驱动动画不是用来替代JS的万金油,但它确实解决了一类非常具体的需求:跟滚动位置强绑定的视觉效果。进度条、入场动画、视差、背景渐变,这些以前需要写一坨JS代码还要记得清理副作用的小功能,现在用几行CSS就能干净地实现。
我在实际项目里把它和传统的IntersectionObserver配合使用:滚动驱动的动画负责纯视觉层面的增强,Observer负责更复杂的业务逻辑(比如统计曝光、触发懒加载),两者各司其职,互不干扰。
目前浏览器支持还在快速扩展中,Safari团队已经在官方博客上确认正在实现,预计一到两个大版本内就会跟上来。对于现在就可以上线的项目,用@supports做渐进增强是最稳妥的策略——既享受了新特性带来的丝滑体验,又没有牺牲任何兼容性。如果你手头正好有一个长页面的项目要改版,不妨选一个最不关键的动画先试试水,我猜你改完之后会想把其他动画也一起换掉。

