做滚动相关的视觉效果,过去十年基本只有一条路:监听 scroll 事件,在回调里算进度、改样式。这条路能跑,但跑得不舒服。滚动是高频事件,回调在主线程;很多代码为了保证拿到的位置是最新的,会在回调里读一次 getBoundingClientRect(),紧接着又写样式,一读一写就把浏览器逼着做强制同步布局。就算套上 requestAnimationFrame 做节流,逻辑和渲染还是挤在同一帧里抢时间。
滚动驱动动画(Scroll-driven Animations)换了个思路:把「动画的进度从哪里来」这件事交给 CSS 引擎。进度在合成阶段计算,动画走合成线程,主线程再忙它也能照常跑。这篇文章不讲概念空转,直接上四个能用的例子,然后把几个一定会踩的坑说清楚。
一、本质:把「时间轴」换掉了
普通的 CSS 动画,进度来源是时间。写 animation: fade 1s linear,浏览器就把这一秒切成若干份,按 linear 映射到 0%~100%。
滚动驱动动画做的事情是:把这条时间轴换成滚动位置。同样的关键帧,同样的缓动,只是驱动它的不再是墙上时钟,而是用户手指的滑动距离。
/* 传统写法:1 秒跑完 */
.thing {
animation: grow 1s linear infinite;
}
/* 滚动驱动写法:滚动距离跑完 */
.thing {
animation: grow linear;
animation-timeline: scroll(root block);
}
注意第二段里的 animation-duration 直接消失了。这不是省略,而是写了也没用——滚动时间轴接管之后,动画时长由滚动量决定,浏览器会忽略时长声明。想调节奏,只能去改 animation-range 或者重排关键帧的百分比分布。
CSS 提供两种时间轴函数,用途完全不同:
scroll():盯着滚动容器本身的滚动进度。滚到顶是 0%,滚到底是 100%。适合进度条、随页面收缩的页头。view():盯着某个元素在视口里的可见过程。元素刚开始进入视口是 0%,完全离开视口是 100%。适合卡片揭示、视差。
二、第一个例子:顶部阅读进度条
结构极其简单,页面最后放一个空盒子就行:
<div class="read-progress" aria-hidden="true"></div>
.read-progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, #ff5f6d, #ffc371);
transform-origin: 0 50%;
scale: 0 1;
animation: read-progress-grow linear;
animation-timeline: scroll(root block);
}
@keyframes read-progress-grow {
from { scale: 0 1; }
to { scale: 1 1; }
}
几个细节值得说:
一,这里没有用 transform: scaleX(),而是用了独立的 scale 属性。独立变换属性和 transform 互不干扰,以后想再加别的位移时不用担心覆盖。它同样受 transform-origin 控制,所以从左向右展开的效果照旧。
二,横向压缩会把背景渐变一起压扁,渐变颜色会跟着挤。这里用纯色或者整体色域平滑的渐变,基本看不出来;如果要放具体图案,老老实实用宽度动画或者裁剪方案。
三,animation-fill-mode 可以不写。因为整个滚动区间都落在动画范围内,不存在「范围之外要保持什么状态」的问题。
四,scroll(root block) 里的 root 指的是根滚动容器。这条最容易出错,后面单独讲。
三、第二个例子:卡片进入视口时揭示
过去这个需求基本是 IntersectionObserver 加一个 .is-visible 类,进入视口就加类,CSS 过渡负责表现。它有个天然的短板:只有「进入」和「离开」两个状态,没有中间进度。想要卡片随滚动逐渐清晰、逐渐归位,只能靠过渡曲线糊过去。
view() 直接给你一整段连续进度:
.card {
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes card-reveal {
from {
opacity: 0;
translate: 0 2.5rem;
scale: 0.95;
}
to {
opacity: 1;
translate: 0 0;
scale: 1;
}
}
animation-range 是这套东西里最有价值的开关。它把整条时间轴切成若干区间,常用的关键字有四个:
entry:元素从「开始进入视口」到「完全进入视口」这一段。contain:元素完全在视口内的那一段。exit:元素从「开始离开视口」到「完全离开视口」。cover:元素和视口从有交集到完全没交集的整个过程。
entry 0% entry 100% 的意思就是:动画在「刚冒头」到「完全进来」之间跑完。如果要让动画更从容,可以把终点往后推到 cover 30% 之类的位置,卡片会在视口里走得更深一点才缓缓归位。
关于 both 这个填充值,有个小地方容易被忽略。卡片此时还在视口下方很远的地方,进度是 0%,如果不写 backwards 或者 both,浏览器会直接渲染元素的默认样式——也就是完全可见。那卡片在进入视口之前就已经是「已揭示」状态,动画自然就看不到了。而 to 那端其实不需要填充,因为动画结束后元素状态和默认状态本来就一致。所以严格来说这里写 backwards 就够,写 both 只是习惯,不算错。
四、第三个例子:hero 图视差
视差在以前是个 JS 性能测试题,现在几行 CSS:
.hero-frame {
overflow: hidden;
block-size: 24rem;
}
.hero-frame img {
inline-size: 100%;
block-size: 130%;
object-fit: cover;
animation: hero-drift linear both;
animation-timeline: view();
animation-range: cover;
}
@keyframes hero-drift {
from { translate: 0 -10%; }
to { translate: 0 10%; }
}
图片高度做到容器的 130%,多出来的部分正好留给位移,外面的 overflow: hidden 负责裁掉超出的区域。animation-range: cover 是整个覆盖区间,图片从能看到到看不见,位移匀速走完,视差感就出来了。
这里只动 translate,属于合成阶段就能处理的属性,不触发布局也不触发重绘,这是它能扛住连续滚动的原因。
五、第四个例子:把时间轴借给别的元素
这是这套特性里我目前觉得最容易被低估的部分。假设页面是两栏:左边是一个可以独立滚动、内容很长的文章列表,右边侧边栏里有一个小标记,希望它随着左边列表的滚动而移动,跟页面本身的滚动没关系。
难点在于,侧边栏的标记和滚动容器是兄弟关系,时间轴默认的作用域管不到它。这时候用 timeline-scope 把时间轴名字提到共同祖先上:
.layout {
/* 把 --list-scroll 这个名字提升到这一层,兄弟也能引用 */
timeline-scope: --list-scroll;
}
.article-list {
overflow-y: auto;
block-size: 100%;
scroll-timeline: --list-scroll block;
}
.side-marker {
animation: marker-slide linear both;
animation-timeline: --list-scroll;
}
@keyframes marker-slide {
from { translate: 0 0; }
to { translate: 0 8rem; }
}
默认情况下,scroll-timeline 声明的名字只在声明它的那个元素以及它的后代里可见。侧边栏是它的兄弟,看不到这个名字,所以需要 timeline-scope 在更高一层把名字「借出来」。这个作用域的规则和 CSS 变量的作用域思路是一致的,理解了变量再理解这个会快很多。
六、实际项目里一定会踩的坑
1. 千万别把 animation-timeline 塞进 animation 简写。第一次写的时候我图省事,写成 animation: reveal linear view(),结果浏览器把 view() 当成了动画名,页面一动不动,排查了大半天。简写里没有时间轴的位置,必须单独一行声明。
2. animation-duration 会被忽略。不要指望写个 0.3s 让动画快点结束。控制快慢的手段只有两个:调整 animation-range 的区间,或者重新分配关键帧的百分比。
3. 滚动源选错是无声的失败。很多项目会在 body 上设 overflow: hidden,然后让一个内部 div 承担全部滚动。这种情况下 scroll(root) 会一直停在 0,进度条纹丝不动,控制台也不报错。正确做法是在那个真正滚动的容器上声明具名时间轴,再用 timeline-scope 透出来,就像上面第五节的写法。
4. 元素高度会改变 view() 的节奏。cover 区间的长度等于视口高度加上元素高度,元素一变高,整个动画的分布就变了。这和 JS 里按滚动距离算进度完全是两码事。如果卡片高度不固定,最好在 animation-range 里用百分比把它约束在一个稳定位置,而不是依赖绝对像素。
5. 读不到进度值。滚动驱动动画把进度计算交给了 CSS,也就意味着你没法像 IntersectionObserver 那样在回调里拿到一个 0~1 的数字。真需要读,得回到 JS:element.getAnimations()[0].currentTime。所以判断标准很清楚——纯表现层的东西用它,一旦需要读进度做逻辑分支,还是老实写观察器。
6. 减少动效偏好要处理。视差、位移、缩放这类会引起不适的动画,在 prefers-reduced-motion: reduce 下应该关掉。进度条这类纯信息指示可以保留,也可以一并简化,看产品取舍。
七、渐进增强的写法,别让旧浏览器开天窗
目前 Chrome、Edge 支持得很完整,Safari 的新版本也已经跟上,Firefox 仍在推进中。真实项目里,用 @supports 把动画和初始状态一起保护起来:
/* 基样式里千万不要写 opacity: 0 */
.card {
opacity: 1;
}
@supports (animation-timeline: view()) {
.card {
opacity: 0;
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
opacity: 1;
animation: none;
}
}
这中间有个真实的生产事故模式:把 opacity: 0 写在不受 @supports 保护的基样式里,然后指望动画把它变回来。在不支持滚动驱动动画的浏览器上,动画根本不会跑,卡片就永久隐身了。所以初始隐藏状态必须和动画写在同一个特性查询块里,同进同退。
八、调试怎么弄
写这类动画不建议靠反复滚动页面看效果,一是慢,二是很难停在某个精确位置观察。开发者工具的 Animations 面板现在会把这些滚动驱动动画列出来,并且提供一个可以直接拖动的进度控件,比手工滚动高效得多。定位问题时,先确认动画有没有出现在列表里——如果压根没出现,那大概率是 animation-timeline 的写法被解析错了,先回去检查简写那个坑。
写在最后
滚动驱动动画真正改变的是一种分工:过去我们把「滚动」当成输入事件,一切都要经过 JS 中转;现在滚动本身就是一条时间轴,CSS 可以直接挂上去。代价是你放弃了对进度的读取权,换来的是主线程的清净。
判断该用哪种方案,我自己的标准很朴素:只影响表现、不需要中途做判断的,交给 CSS;需要根据位置切换内容、加载数据、改变结构的,继续用 IntersectionObserver。两套东西不冲突,混着用才是常态。

