凡是做过「页面往下滚,元素跟着动」这类效果的人,都写过类似的代码:window.addEventListener('scroll', ...),回调里读 scrollY,算比例,改 style.transform。写完之后总要在里面塞一个节流或者 requestAnimationFrame,免得在低端安卓机上滚一下卡三下。
这套做法的问题是方向反了。滚动位置本身就是浏览器在合成线程里维护的状态,我们却把它拉回主线程,计算一遍,再写回样式,最后让浏览器重新合成。主线程一旦被别的活儿占住,这个循环就断了,动画看起来就像在打嗝。
滚动驱动动画(Scroll-driven Animations)换了个思路:不把滚动位置读出来,而是直接告诉浏览器「这条动画的进度由滚动位置决定」。剩下的排版、求值、合成全在浏览器那边完成,主线程彻底不掺和。而且滚动和动画天然同步,不会再出现「元素飘了一下才贴上去」的错位感。
这篇不讲抽象概念,直接把三类最常见的滚动效果从头写一遍。写完你会发现,整个过程一行 JS 都不需要。
两个时间轴函数,别搞混
API 里有两个给 animation-timeline 用的函数,长得很像,用途完全不同。
scroll():进度跟着某个滚动容器走。容器从顶滚到底,进度从 0% 走到 100%。它不关心你页面里有什么元素,只关心滚动条的位置。适合用来做阅读进度条、页面级的视差。
view():进度跟着元素自己的可见性走。目标元素从刚好进入滚动视口算起,到完全离开为止,这一整段是它的进度区间。适合做元素入场、逐个浮现这类效果。
两者的参数都可以留空。留空的时候,浏览器会自己找「最近的可滚动祖先」。这个默认行为在简单页面里没问题,但页面结构一复杂就容易选错,后面专门说这个坑。
实战一:顶部阅读进度条
先做最简单的。一条横在页面顶部的细线,随着阅读进度从左往右长满。
结构很简单,放在 body 最上面就行:
<div class="progress-bar"></div>
<main>
<h1>一段长一点的文章</h1>
<p>...</p>
</main>
CSS 部分:
.progress-bar {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 3px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transform-origin: left center;
transform: scaleX(0);
animation: progress-grow linear;
animation-timeline: scroll(root block);
}
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
几个细节值得单独讲一下。
scroll(root block) 里的 root 指的是根滚动容器,也就是 <html> 那个滚动条。留空的话浏览器会去找最近的可滚动祖先,但这里进度条是 fixed 定位,它的祖先里根本没有滚动容器,容易出意外。写明确了最稳。
第二个参数 block 是轴向。你几乎永远都该写 block 而不是 inline——因为页面主滚动方向是垂直的,对应 block 轴;只有横向滚动的轮播、横向时间线才用 inline。
另外注意 animation 简写里只写了 linear,没写时长。这条动画没有时长,它的进度完全由滚动位置决定,所以写不写时长都一样。但为了在不支持滚动驱动动画的浏览器里有个正常表现,实际项目里我会补一个:
animation: progress-grow 1ms linear;
让它在老浏览器里瞬间走完,走成 scaleX(1)——虽然没动画,但也不会停在 0 看不见。
还有个小地方:transform-origin 默认是 center,如果不改成 left center,进度条会从中间往两边长,看起来像是往两边扩散的,很怪。
实战二:卡片入场动画
阅读进度条是页面级的,接下来做元素级的。滚动到一个卡片,它从前下方淡入滑上来。
这个用 view()。
.reveal {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 5% cover 35%;
}
@keyframes reveal-up {
from {
opacity: 0;
translate: 0 40px;
}
to {
opacity: 1;
translate: 0 0;
}
}
然后是全场最关键的那行:animation-range。
如果不写它,默认进度区间是 cover 0% 到 cover 100%。意思是说,从元素刚开始出现在滚动视口底部,到它完全从顶部离开,整段过程都是动画的进度。这会导致一个很尴尬的现象:那个卡片在屏幕里飘着飘着已经到中间了,透明度才刚到 60%,等你滚到它下面去,它自己也快淡出了。看着以为是滚动抖。
所以必须自己圈定一个更窄的区间。这里写的是 entry 5% cover 35%,读法是「起点是元素刚进入视口后走过 5% 的那一刻,终点是整个出没过程走完 35% 的位置」。
四个区间名字得记住:
cover:元素从开始出现在视口边缘,到完全从对侧离开,全过程entry:元素从开始出现,到完全进入视口(在视口内完整可见)exit:元素从完全可见,到完全离开视口contain:元素在视口内完整可见的那一段
所以常见的用法是:cover 用来做一整个来回的视差,entry 用来做入场,exit 用来做出场。这里两个都用了,是因为希望它开始动得早一点、结束得也早一点。
动画里的 both 不能省。它让动画在进度区间之外的时段也保留首尾状态:还没进区间,元素保持 0 透明;出了区间,保持 1 透明。不写的话,动画一旦结束就会恢复成元素的原始样式——也就是「啪」一下跳到终态,同时之前所有过渡效果全白做。
把 animation-fill-mode 写进简写里的顺序是 animation: name duration timing-function ... fill-mode,所以这里 linear both 就是这么来的。
实战三:视差与粘性标题
视差的核心就是让元素在滚动过程中有一个跟滚动方向相反的小位移。传统写法要读滚动位置再乘系数,用滚动驱动动画,只要让动画的进度区间包住它整个可见周期就行。
.parallax-img {
animation: float linear both;
animation-timeline: view();
animation-range: cover;
}
@keyframes float {
from { translate: 0 -60px; }
to { translate: 0 60px; }
}
这里故意用 cover 整段。因为视差要一眼看不出来那是「动画」,只是感觉图片和周围的文字速度不一样。这类效果不要做太夸张,位移超过 100px 就容易让人头晕。
接下来这个更有意思:让一个区块在滚过它的时候,标题固定在顶部不动,等它彻底滚过去再自然放开。
.section-title {
position: sticky;
inset-block-start: 0;
z-index: 2;
}
.section {
animation: sticky-release linear both;
animation-timeline: view();
animation-range: exit 0% exit 100%;
}
@keyframes sticky-release {
from { opacity: 1; }
to { opacity: 0.2; }
}
这里的核心是在 view() 上再给一个 inset 参数。它可以把滚动视口往里收一圈,改变所有区间边界的位置。比如 view(block 80px) 会把 block 轴两端的判定线各往里收 80px,元素要到进入这个收窄后的区域才算起点。
这个用法在头部有固定导航条的页面里很有用。因为你的滚动视口顶部其实被导航条盖住了 60px,如果按原始的 view() 算,元素会在被盖住的那一段就开始触发动画,用户看不到。
.reveal {
animation-timeline: view(block 60px 0px);
}
一行两个值,分别是起点和终点的偏移,正好对应顶部和底部。顶部 60px 是因为有导航条,底部 0 表示不变。
坑:选错了滚动容器
这个是我调试最久的一个问题,不单独讲一下不放心。
假设页面长这样,移动端很常见:外层一个 <div class="page"> 带 overflow-y: auto,页面所有内容都在它里面滚。那么你在子元素上写 animation-timeline: scroll(),浏览器往上找最近的滚动祖先,确实会找到这个 page,这是对的。
但如果 page 的样式写成了 overflow: hidden,而滚动其实发生在更外层的某个容器上呢?浏览器的「最近滚动祖先」算法在这一层会跳过 overflow: hidden 的元素(因为它不可滚动),继续往上找,结果找到的可能是 <html>,而你预期的其实是中间那层。进度就全错了。
解决办法有两个。
一是给滚动容器一个名字,然后在动画里引用它。这需要两者之间互相能找到:
.scroller {
overflow-y: auto;
timeline-scope: --main-scroll;
scroll-timeline: --main-scroll block;
}
.inside {
animation-timeline: --main-scroll;
}
timeline-scope 加在滚动容器上,等于把这条时间轴声明到自己的作用域里。子元素里的 animation-timeline 用 --main-scroll 这个名字去引用它就行。这个写法比用无名时间轴明确得多,重构的时候也不容易断。
另一个更省事的办法:如果只是偶尔某个元素对不上,把它从错误的滚动上下文里搬出来,用 position: fixed 或者干脆放到根节点旁边,让 scroll(root block) 直接管它。
还有一个新手会踩的:position: fixed 的元素不能作为 view() 的目标。因为固定定位的元素相对视口不动,它永远在视口里可见,进度永远恒定为 1。用它做入场动画,得到的是一块僵在原地的色块。这个报错不会提示,只能自己看出来。
性能:什么时候需要 will-change
滚动驱动动画不用手动挂 will-change,浏览器在遇到 animation-timeline 的时候会自己把它提升到合成层。这一点比手写 scroll 监听友好太多了。
反过来,如果你在动画里改的是 margin、top、height 这类会触发重排的属性,提升到合成层也救不了——每一帧都要重新算布局。原则还是那一条:
/* 会让浏览器每一帧重算布局 */
@keyframes bad {
from { margin-block-start: 0; }
to { margin-block-start: 80px; }
}
/* 只影响合成层 */
@keyframes good {
from { translate: 0 0; }
to { translate: 0 80px; }
}
translate、scale、rotate、opacity、filter、clip-path 这几个可以随便用。translate 和 transform: translate() 效果一样,但 translate 是独立属性,不会被别的 transform 覆盖掉,写多个动效时不容易打架,建议优先用它。
减弱动效和降级
滚动驱动的视差效果,对前庭功能敏感的用户来说是灾难性的。系统里那个「减弱动态效果」开关,一定要接上:
@media (prefers-reduced-motion: reduce) {
.reveal,
.parallax-img,
.section {
animation: none;
}
.progress-bar {
animation: none;
transform: scaleX(1);
}
}
注意进度条这里不是简单关掉,而是让它变成一整个满格。因为对用户来说「页面已经读完了」这个信息本身是有用的,只是它不需要有一个动态的过程。
降级方案用 @supports 判断:
@supports not (animation-timeline: scroll()) {
.progress-bar {
animation: none;
transform: scaleX(0);
}
.reveal {
animation: none;
opacity: 1;
translate: none;
}
}
不支持的时候,两种处理方式:进度条直接藏起来(没有就没信息),元素直接显示为最终状态(能看就行)。不要试图用 JS 去模拟一遍,那就回到了这套方案本来想避开的老路上,得不偿失。
完整代码
把上面所有东西拼起来。建议存成两个文件,避免混淆。
先看结构,index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>滚动叙事示例</title>
<link rel="stylesheet" href="styles.css" rel="external nofollow" >
</head>
<body>
<div class="progress-bar" aria-hidden="true"></div>
<main class="article">
<header class="hero">
<h1>翻过这座山</h1>
<p>一篇关于行走与记录的随笔</p>
</header>
<section class="section">
<h2 class="section-title">出发</h2>
<p>清晨五点,天还没亮透。背包的重量压在肩上,比想象中沉一些。</p>
</section>
<figure class="parallax">
<img class="parallax-img" src="ridge.jpg" alt="山脊线">
</figure>
<section class="section">
<h2 class="section-title">半途</h2>
<p>海拔升高之后,呼吸开始变得有节奏,一步一吸,两步一呼。</p>
</section>
<section class="section">
<h2 class="section-title">登顶</h2>
<p>风比山下大很多,云在脚下缓慢移动,像另一种海。</p>
</section>
<div class="cards">
<article class="card reveal"><h3>装备清单</h3><p>冲锋衣、头灯、备用电源、一壶热水。</p></article>
<article class="card reveal"><h3>时间规划</h3><p>预留比预计多一倍的下撤时间,永远不要压着日落走。</p></article>
<article class="card reveal"><h3>身体信号</h3><p>头痛、恶心、说话开始重复,都是需要立刻下降的信号。</p></article>
</div>
</main>
</body>
</html>
然后是 styles.css:
* { box-sizing: border-box; }
body {
margin: 0;
font: 17px/1.8 -apple-system, "Segoe UI", "PingFang SC", sans-serif;
color: #22252a;
background: #fbfbfc;
}
.article {
max-width: 720px;
margin: 0 auto;
padding: 0 24px 120px;
}
.hero {
min-height: 70vh;
display: flex;
flex-direction: column;
justify-content: center;
}
.hero h1 {
font-size: clamp(40px, 8vw, 72px);
line-height: 1.1;
margin: 0 0 12px;
letter-spacing: -0.02em;
}
.hero p {
color: #6b7076;
margin: 0;
}
.section {
margin: 120px 0;
}
.section-title {
position: sticky;
inset-block-start: 0;
z-index: 2;
margin: 0 0 24px;
padding: 16px 0;
font-size: 28px;
background: linear-gradient(#fbfbfc 70%, transparent);
}
.parallax {
margin: 0;
overflow: clip;
border-radius: 16px;
block-size: 60vh;
}
.parallax-img {
inline-size: 100%;
block-size: 130%;
object-fit: cover;
display: block;
animation: float linear both;
animation-timeline: view();
animation-range: cover;
}
.cards {
display: grid;
gap: 20px;
}
.card {
padding: 28px;
background: #fff;
border: 1px solid #e6e8ec;
border-radius: 16px;
}
.card h3 {
margin: 0 0 8px;
font-size: 19px;
}
.card p {
margin: 0;
color: #5a6068;
}
/* ==== 滚动驱动动画 ==== */
.progress-bar {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 3px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transform-origin: left center;
transform: scaleX(0);
z-index: 10;
animation: progress-grow 1ms linear both;
animation-timeline: scroll(root block);
}
.reveal {
animation: reveal-up linear both;
animation-timeline: view(block 60px 0px);
animation-range: entry 5% cover 35%;
}
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@keyframes reveal-up {
from {
opacity: 0;
translate: 0 40px;
}
to {
opacity: 1;
translate: 0 0;
}
}
@keyframes float {
from { translate: 0 -60px; }
to { translate: 0 60px; }
}
@supports not (animation-timeline: scroll()) {
.progress-bar {
animation: none;
transform: scaleX(0);
}
.reveal {
animation: none;
opacity: 1;
translate: none;
}
.parallax-img {
animation: none;
translate: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal,
.parallax-img {
animation: none;
opacity: 1;
translate: none;
}
.progress-bar {
animation: none;
transform: scaleX(1);
}
}
把这两个文件放在同一个目录,用浏览器打开 index.html 就能看到完整效果。图片那个路径随便换张自己电脑上的图就行,或者暂时用渐变背景代替也能跑。
最后一点想法
刚接触滚动驱动动画的时候,我的第一反应是「这玩意儿是不是又是个玩具特性,各种浏览器不统一」。但真正拿它改完几个页面之后发现,它解决的恰恰是很实际的问题。
过去我们要么在主线程里硬撑,要么用 IntersectionObserver 加一堆状态判断来做入场动画——前者卡,后者代码难看。现在这两种场景都被下放到了浏览器层,代码行数从几十行变成几行,滚动和动画的同步关系还比以前更准。
真正需要留意的是降级路径。别把页面的核心信息藏在「滚动到这里才出现」的动画后面。动画应该是给已经能看见的内容加一层修饰,而不是内容本身的可见条件。这个原则守住,兼容性再差也不会把用户卡在那儿。

