构建一个完整的“滚动故事”页面,声明式地让动画跟着滚动进度走
一个让我对“滚动动画”彻底改观的下午
去年我在做一个品牌故事页面,需求很典型:用户往下滚动的时候,一张产品图片从侧面滑入,同时旁边的文字逐渐显现;滚动到某个区域时,背景颜色需要平滑过渡;页面底部有个区域,中间的数字要像抽奖一样滚动增长。用JavaScript实现这些效果并不难——监听window.scroll事件,计算元素位置,动态修改style属性。但当我把这些逻辑写完,scroll事件处理函数已经膨胀到了将近80行,而且页面在低端手机上滚动时明显卡顿,因为scroll事件触发得非常密集,每一帧都在做DOM操作。
试了requestAnimationFrame节流、用IntersectionObserver部分替代滚动计算,代码确实流畅了一些,但复杂度又上了一个台阶。后来我在Can I Use上偶然看到@scroll-timeline这个属性,顺着文档一路查下去,才发现浏览器已经在实现一套完整的“滚动驱动动画”机制——不需要一行JavaScript,动画直接由滚动条的位置驱动。那个下午我把之前写的80行JS全部删掉,换成大约40行CSS声明,动画效果完全一样,但页面滚动变得丝滑无比。
现在这套机制有了正式的名字:Scroll-driven Animations,核心就是让CSS动画的时间线跟滚动进度绑定,而不是机械地走秒数。这篇文章会用一个大案例把它讲清楚,你不需要提前懂任何新概念,我会从零开始搭,让你看完就能在自己的项目里用起来。
先搞懂两个核心概念:ScrollTimeline和ViewTimeline
传统的CSS动画用animation-duration定义时长,比如2s,时间到了动画就结束。滚动驱动动画把这个“时间线”换成了“滚动进度”。你可以想象页面顶部是动画的起点,底部是动画的终点,用户滚动到哪个位置,动画就播放到对应的位置。这个虚拟的时间线叫ScrollTimeline。
还有一种更精细的控制:基于某个元素是否进入可视区域以及它在可视区域内的位置来驱动动画。比如一个元素从视口底部刚露头时动画开始,完全滚出视口顶部时动画结束,这叫做ViewTimeline。它很适合做那种“元素进入屏幕时触发”的动画,不需要计算全局滚动位置。
这两个概念是滚动驱动动画的基石。在CSS里,你只需要三样东西:
- 一组
@keyframes定义动画的关键帧(和以前一模一样)。 - 一个
animation-timeline属性,让动画绑定到滚动进度而不是时间。 - 一个时间线的定义,可以用
scroll()或view()简写函数,也可以用@scroll-timeline规则显式定义(后者在最新的规范里正在被简写函数取代,本文用简写方式)。
和传统的animation属性配合使用时,你不需要设置animation-duration,因为动画的“时长”由滚动距离决定。是不是听起来有点抽象?没关系,下面直接看代码。
第一个例子:一条跟着滚动条走的进度条
我们要做的第一个组件很简单:页面顶部固定一个水平进度条,它从0%开始,当用户滚动到页面底部时增长到100%。这大概是最入门的滚动驱动动画案例,但它能让你直观地感受“动画绑定滚动”到底是怎么运作的。
HTML结构:
<div class="progress-bar"></div>
<main>
<!-- 页面主要内容,高度足够产生滚动 -->
</main>
对应的CSS:
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: #6c5ce7;
transform-origin: left center;
animation: grow-progress 1s linear;
animation-timeline: scroll(root);
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
重点在animation-timeline: scroll(root);这一行。scroll(root)表示以根元素的滚动容器(也就是浏览器视口对应的文档滚动)作为时间线。滚动条在最顶部时,动画处于from状态(scaleX为0);滚动条滚到最底部时,动画到达to状态(scaleX为1)。中间的所有过渡由浏览器自动计算,帧级精准,比任何JavaScript实现都要平滑。
你可能会注意到我还写了一个animation-duration: 1s,这其实是一个兜底策略:如果浏览器不支持滚动时间线,就会退化为普通的1秒动画,虽然达不到预期效果,但至少不会让进度条完全不出现。写animation简写属性时可以这样写:animation: grow-progress 1s linear both;,然后在下面另起一行animation-timeline: scroll(root);单独覆盖时间线。支持滚动时间线的浏览器会忽略duration,用滚动进度替代;不支持的浏览器则使用1秒的时长播放动画。
用ViewTimeline做“元素进入屏幕时播放”的动画
进度条搞定之后,我们来做页面主体部分的动画——图片从左侧滑入,文字从右侧淡入。CSS代码如下:
.feature-image {
animation: slide-in-left 1s ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
.feature-text {
animation: fade-in 1s ease-out both;
animation-timeline: view();
animation-range: entry 20% cover 50%;
}
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-80px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
这里出现了两个新东西:view()和animation-range。
view()简写函数创建一个基于元素自身可见性的时间线。它的工作方式是这样的:当元素开始进入视口底部时,动画的起点对应时间线0%;当元素完全离开视口顶部时,动画的终点对应时间线100%。通过animation-range可以精确控制动画在时间线的哪一段播放。
animation-range: entry 0% cover 40%;这句话的意思是:动画从元素刚进入视口底部(entry 0%)开始,到元素被视口覆盖了40%时(cover 40%)结束。简单来说,它让图片在滑入屏幕大约40%的位置时完成动画,而不是默认的完全穿过视口。这样当用户看到这块内容时,动画已经播放完毕,不会有等待感。
相比IntersectionObserver和手写滚动计算,这套声明式语法的精确度和可维护性都高了一个层级。而且因为动画直接由渲染引擎驱动,它完美避开了JavaScript主线程的阻塞,即使页面在做复杂计算,滚动动画依然流畅。
完整实战:构建一个多层级的滚动故事页面
现在我们把前面学到的内容组合成一个完整的落地页。页面包含四个区块:
- 顶部区域:大标题,背景色随滚动从浅灰过渡到深蓝。
- 产品展示区:左侧图片从左边滑入,右侧描述文字淡入。
- 数据展示区:三个数字从0滚动增长到目标值。
- 底部CTA区:按钮在区域进入视口时弹性放大。
我们逐一来实现。
3.1 背景色渐变:用ScrollTimeline让整个页面的背景平滑过渡
传统做法是监听全局滚动位置,计算一个百分比,然后动态设置body的背景色。现在用滚动驱动动画,可以直接在body上写动画:
body {
animation: bg-shift 1s linear both;
animation-timeline: scroll(root);
}
@keyframes bg-shift {
0% { background-color: #f5f5f5; }
100% { background-color: #1a1a2e; }
}
当用户从页面顶部滚动到底部,背景色就会从浅灰过渡到深蓝。如果想把这个过渡限制在页面前半部分完成,可以在body上设置animation-range: 0% 50%;,意思是滚动到页面中间时背景色就变成深蓝,后半部分保持不变。
3.2 数字滚动增长:利用counter-reset和动画搞定的纯CSS效果
数字滚动增长其实不需要滚动驱动动画也能做,但结合滚动的意义在于——只有当用户滚动到数据展示区域时,数字才开始变化。否则用户一打开页面,底部的数字已经在跳动了,体验并不好。我们用ViewTimeline来控制数字动画的播放时机。
HTML:
<div class="stat">
<span class="stat-number" data-target="1578">0</span>
<span>满意客户</span>
</div>
CSS核心思路是利用@property定义一个自定义属性(比如--num),然后用关键帧动画改变这个属性,同时用counter-reset结合伪元素把数值显示出来。把动画时间线绑定到元素的view()上,就实现了“滚动到这个区域时数字开始跳动”的效果。
@property --num {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.stat-number {
counter-reset: display-value var(--num);
animation: count-up 2s ease-out both;
animation-timeline: view();
animation-range: entry 10% cover 30%;
}
.stat-number::after {
content: counter(display-value);
}
@keyframes count-up {
from { --num: 0; }
to { --num: 1578; }
}
这个效果以前需要requestAnimationFrame里一帧一帧地插值,现在完全交给CSS引擎处理,性能开销几乎为零。而且注意到animation-timeline: view()让动画在元素进入可视区域后才播放,避免了用户看不到的地方还在默默执行动画造成的资源浪费。
3.3 按钮弹性放大:给CTA增加视觉吸引力
底部的CTA按钮我们希望它在进入视口时有一个弹性的缩放效果——先快速放大到110%,再回弹到100%。这可以用关键帧实现:
.cta-button {
animation: bounce-in 0.8s cubic-bezier(0.68, -0.55, 0.27, 1.55) both;
animation-timeline: view();
animation-range: entry 20% cover 50%;
}
@keyframes bounce-in {
0% { transform: scale(0.8); opacity: 0; }
60% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
这个弹性曲线cubic-bezier让缩放效果超出了目标值再回弹,配合上ViewTimeline,只有当用户滚动到接近底部时按钮才会弹出来,时机恰到好处。
动画的精细控制:animation-range的深度用法
前面已经多次提到animation-range,这里稍微系统地梳理一下。它是滚动驱动动画里最精细的调节旋钮,允许你把动画精确地映射到滚动进度的某一段区间。
对于ViewTimeline,你使用的范围名称有cover、contain、entry、exit等。常用的组合是:
entry 0%—— 元素刚开始进入视口底部。cover 100%—— 元素完全覆盖视口。exit 100%—— 元素刚完全离开视口顶部。
你可以把它们组合成区间:animation-range: entry 10% cover 40%;表示从元素进入10%的位置到覆盖40%的位置之间播放动画。这个机制让你能精确地控制每个元素的动画开始和结束时机,创造出元素分别在不同时间点启动的层次感,而不是所有东西一滚进屏幕就同时动起来。
对于ScrollTimeline,animation-range使用百分比直接对应滚动进度。比如animation-range: 20% 80%;表示动画在页面滚动到20%处开始,滚动到80%处结束。这个用法比单纯操作scroll(root)更灵活。
浏览器支持情况与渐进增强策略
说实话,滚动驱动动画目前还不是所有浏览器都支持。Chromium内核的浏览器(Chrome 115+、Edge 115+)已经完整支持,Firefox和Safari还在逐步实现中。不过好在几个关键特性——scroll()和view()简写函数——已经被主流浏览器引擎纳入了积极开发的路线图。
对于生产环境,我的建议是采用渐近增强的策略:
- 在CSS里为滚动驱动动画相关属性单独写一条规则,用
@supports (animation-timeline: scroll())包裹起来。这样不支持滚动时间线的浏览器会直接忽略这些规则,不会产生任何副作用。 - 为每个关键帧动画保留一个
animation-duration作为回退值。如果浏览器不支持滚动时间线,动画会按照固定的1秒或2秒自动播放,虽然失去了滚动联动效果,但至少元素能正常出现,不会一直不可见。 - 使用
@supports not (animation-timeline: scroll())为不支持的环境编写替代的动画播放逻辑(当然这通常回到JavaScript方案)。不过对于大多数纯展示性的动画,没有滚动联动也能接受。
下面是一个示例写法:
.feature-image {
/* 回退动画:默认1秒播放 */
animation: slide-in-left 1s ease-out both;
}
@supports (animation-timeline: view()) {
.feature-image {
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
这样老浏览器也能看到一个动画效果(只是时机不对),而现代浏览器则获得完美的滚动联动体验。
调试小技巧:在DevTools里观察时间线
Chrome 115之后的DevTools直接支持滚动时间线的可视化调试。打开开发者工具,找到“Animations”面板,当页面有基于滚动时间线的动画运行时,你会看到时间线不再是一条固定长度的进度条,而是一条会跟随你滚动而移动的标记。你可以在这个面板里手动拖动滚动位置,观察动画每一帧的状态,对调试animation-range特别有帮助。这个工具让我省去了大量来回滚动测试的时间。
哪些场景最适合用滚动驱动动画
根据我这段时间的实践,下面这些场景用滚动驱动动画收益最大:
- 长篇叙事页面:品牌故事、产品介绍、年度报告等需要引导用户一步步阅读的页面。
- 数据看板:当用户滚动到对应的统计区域时,数字从0增长到目标值,比静态展示更有冲击力。
- 视差滚动:不同层级的元素以不同速度滚动,用
animation-timeline配合translateY能实现类似视差的效果,而且比JavaScript方案更顺滑。 - 进度指示器:文章阅读进度条、多步骤表单的步骤指示器等,都是经典的ScrollTimeline用例。
但不适合的场合也很明显:那些需要在动画播放过程中响应用户交互(比如悬停、点击)的复杂场景,目前单纯用CSS还不够,还是需要JavaScript配合。
写在最后
写完那个品牌故事页面之后,我把其中一个中间件产品介绍的动画效果发给设计同事看,她的第一句话是:“你这次加了多少JavaScript?动画好顺畅。”当我告诉她一行JavaScript都没加的时候,她愣了几秒,然后说:“那以后这种页面我是不是可以直接自己改了?”我点了点头。这才是滚动驱动动画真正让我兴奋的地方——它把动画的创作权从开发者的代码编辑器里,部分地交还给了样式本身。设计意图可以用CSS更直接地表达,不需要经历“设计稿→开发者理解→JavaScript实现→效果有偏差→反复调整”的漫长链条。
如果你已经习惯了用scroll事件和getBoundingClientRect来做滚动动画,不妨找个内部项目试试这套新的CSS方案。从那个全局进度条开始,一步一步替换掉旧代码。你会发现自己再也不想回到那些臃肿的scroll事件处理函数里去了。

