项目里有个阅读型页面,顶部要一条随滚动增长的进度条,正文里的卡片要边滚边淡入。按老办法,一个 scroll 监听加 requestAnimationFrame 节流,再挂几个 IntersectionObserver,能跑,但每次滚动都在主线程上算样式,低端机上肉眼可见地掉帧。
去年底 Chrome 把滚动驱动动画(Scroll-driven Animations)的 scroll() 和 view() 正式发版之后,这块东西基本可以从 JS 里整个删掉。我把那个页面重写了一遍,JS 从六十多行降到零。下面是从查文档到跑通的全过程,包括几个卡了我一阵子的坑。
先说清楚它到底在干什么
普通 CSS 动画的时间轴是文档时间:从 animation 开始计时,按秒往前走。滚动驱动动画做的事,是把时间轴换成了别的东西——
scroll():把某个滚动容器的滚动进度当作时间轴。滚到顶是 0%,滚到底 100%。
view():把元素自身在视口里露出的程度当作时间轴。元素刚碰到视口下边缘是 0%,完全穿过视口是 100%。
关键在于,这个进度是浏览器在合成线程上算的,不经过主线程。滚动的时候主线程就算被一堆 JS 堵着,动画照样丝滑。这是它和 scroll 事件 + JS 改样式最本质的区别——不是写法更短,而是执行路径根本不同。
案例一:顶部阅读进度条
整个滚动容器是页面本身,所以时间轴绑在根滚动容器上。
<div class="progress"></div>
.progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, #f97316, #ec4899);
transform-origin: 0 50%;
transform: scaleX(0);
animation: fill linear;
animation-timeline: scroll(root block);
}
@keyframes fill {
to { transform: scaleX(1); }
}
几个细节说明一下。
scroll(root block) 里的 root 指的是根滚动容器,block 是块轴方向——竖排文档就是竖直方向,哪天要做横向翻页的进度条就换成 inline。
用 scaleX 而不是改 width。改宽度会触发重排,虽然这里是合成层上的动画也能跑,但宽度的变化会连带动布局,没必要。配合 transform-origin: 0 50%,缩放从左边长出去,效果和宽度增长一模一样。
animation 简写里没写时长。滚动驱动动画不需要 animation-duration,写了也会被忽略。这里 linear 是必须的,因为默认的 ease 会让进度条在开头和结尾变速,看起来像卡了一下。
还有 transform: scaleX(0) 这行初值。如果页面加载时已经处于滚动位置(比如刷新后浏览器恢复滚动位置),动画会立刻按下当前进度渲染,不会出现先看到一条满格再缩回去的闪烁。
案例二:卡片滚入淡出
正文里的卡片,要求是“进入视口 60% 时刚好完全显示,完全离开视口时再淡出”。这次用 view()。
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 20% exit 40%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(28px) scale(0.97); }
to { opacity: 1; transform: none; }
}
animation-range 是这套东西里最需要花点时间理解的部分。它规定了动画从时间轴的哪个位置开始、到哪个位置结束。entry、exit、cover、contain 这几个名字分别对应元素穿过视口的不同阶段:
cover:从元素开始碰到视口,到完全离开视口,整个生命周期。
entry:从元素开始进入视口,到完全进入视口。
exit:从元素开始离开视口,到完全离开视口。
所以 entry 20% exit 40% 的意思是:进入视口 20% 的位置开始播,离开视口 40% 的位置播完。中间的 contain 阶段动画停在最后一帧。
这里带了个 both。因为卡片默认应该在视口下方时是隐藏的,而动画的 from 关键帧里才是隐藏状态,所以必须让它在 animation-range 开始之前就保持在 from。不加 both,卡片在你滚到它之前会以正常状态显示出来,然后滚到触发点突然闪一下消失再淡入——这个现象我第一版就中招了。
五个坑,按遇到的先后顺序排
坑一:animation-timeline 写在 animation 前面就没了
这条最阴。
/* 错误写法 */
.card {
animation-timeline: view();
animation: reveal linear both;
}
animation 是简写属性,它会把自己包含的所有子属性重置成初始值,而 animation-timeline 就在这个重置范围里。浏览器按书写顺序解析,先设置的时间轴立刻被后面的简写干掉,动画于是回退到文档时间轴,元素瞬间播完。表现出来就是“什么都没发生”。
规矩很简单:animation-timeline 永远写在 animation 之后。或者干脆全程用长写属性,别碰简写。
坑二:scroll() 找不到滚动容器就是死
我给一个内部滚动区域做进度条,直接把 scroll() 挂上去了,结果一动不动。原因是 scroll() 不带参数时默认绑定最近的滚动祖先,而那个区域外层只有一个 overflow: auto 忘了设高度、实际上没滚起来的 div。没有滚动条就没有进度,动画从头到尾停在 0%。
这种场景用命名时间轴更稳,把名字定义在滚动容器上,动画绑名字,不用靠“最近祖先”去猜:
.panel {
overflow-y: auto;
height: 420px;
scroll-timeline: --panel block;
}
.panel-progress {
animation: fill linear;
animation-timeline: --panel;
}
坑三:降级不写,旧版 Safari 上整页元素不显示
这是最要命的一个。移动端还有不少设备跑不认 animation-timeline 的浏览器。这些浏览器读到 animation: reveal linear both 里的 both,会把 from 关键帧的状态直接套上去,而且永远不会往前走——结果就是所有卡片永久停留在 opacity: 0。页面看起来像坏了一样,白花花一片。
解决办法是把整套动画包进特性查询,不支持的浏览器压根看不到这些规则:
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 20% exit 40%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(28px) scale(0.97); }
to { opacity: 1; transform: none; }
}
}
注意 @keyframes 我也一起放进去了。虽然单独放外面通常也能跑,但放在里面语义更清楚:不支持时间轴的环境下,这些关键帧本来就用不上。
坑四:prefers-reduced-motion 之后元素又集体消失
无障碍这块要处理,但处理方式不能只是把动画关掉。前面说了,from 是隐藏状态,你把动画整个去掉,元素倒是显示了;可如果你只是把 animation 的时长改成 0s 或者置成 none 而保留了 both,元素就会卡在 from 上。
正确的做法是把动画整体撤掉,让元素回到默认样式:
@media (prefers-reduced-motion: reduce) {
.card,
.progress {
animation: none;
transform: none;
opacity: 1;
}
}
坑五:animation-play-state: paused 不是用来做延迟的
我想让卡片“稍微晚一点”开始淡入,下意识写了 animation-play-state: paused 想去卡住它。没用。滚动驱动动画的进度完全由滚动位置决定,play-state 只能整体冻结,不会让它“晚点开始”。延迟是靠 animation-range 的起始百分比调的,比如改成 entry 30%,就自然晚了。
完整可跑的页面
把上面几段合起来,一个文件就能验证效果。滚动条拖到底,进度条满格,卡片依次淡入淡出,全程没有一行 JS。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
line-height: 1.8;
padding: 0 20px;
background: #0f1115;
color: #d8dee9;
}
article { max-width: 640px; margin: 0 auto; padding: 40px 0 80vh; }
.progress {
position: fixed; inset: 0 0 auto 0; height: 3px;
background: linear-gradient(90deg, #f97316, #ec4899);
transform: scaleX(0);
transform-origin: 0 50%;
}
.card {
background: #1a1e26;
border-radius: 12px;
padding: 24px;
margin: 32px 0;
border: 1px solid #262b36;
}
@keyframes fill { to { transform: scaleX(1); } }
@keyframes reveal {
from { opacity: 0; transform: translateY(28px) scale(0.97); }
to { opacity: 1; transform: none; }
}
@supports (animation-timeline: scroll()) {
.progress {
animation: fill linear;
animation-timeline: scroll(root block);
}
}
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 20% exit 40%;
}
}
@media (prefers-reduced-motion: reduce) {
.progress, .card { animation: none; transform: none; opacity: 1; }
}
</style>
</head>
<body>
<div class="progress"></div>
<article>
<h1>滚动试试</h1>
<div class="card">第一张卡片</div>
<div class="card">第二张卡片</div>
<div class="card">第三张卡片</div>
</article>
</body>
</html>
要不要现在就用
纯展示类的滚动效果——进度条、视差分层、元素入场、章节标题吸顶——基本可以放心上了,配合 @supports 降级之后,老浏览器只是少了动效,内容一点不受影响,这个代价完全可以接受。
但如果动画里要出现计算结果、要根据业务数据动态变形,或者需要跟用户交互(比如拖拽、点击)产生联动,那还是老老实实用 JS。animation-timeline 只是把“时间从哪来”这件事交给浏览器,它并不打算取代业务逻辑。
另外还有两个方向值得留意:一个是 scroll-state() 查询,能让样式直接响应“是否吸顶”“是否滚到边界”这类状态;另一个是给动画显式指定命名时间轴之后的链式组合。等这两个在主流浏览器上稳下来,滚动相关的 CSS 大概还能再省掉一批 JS。

