顶部阅读进度条、往下滚时卡片一个个浮上来、横向滑动的画廊里图片跟着缩放——这几类效果以前基本都得住进 JavaScript 里:监听 scroll、算 getBoundingClientRect、再塞个 class。代码不难,但麻烦,而且每次滚动都触发一次读写,卡不卡全看运气。
现在这套活可以直接在 CSS 里干完。核心只有三个东西:animation-timeline、animation-range,以及一个能被命名的时间线。本文从最基础的进度条开始,一路写到命名时间线,中间把几个容易踩的坑说清楚。
一、先理解”时间线”这个词在说什么
平时写 @keyframes 加 animation: fade 0.3s,动画的进度来源是文档时间轴——也就是挂钟。0 到 0.3 秒之间,进度从 0% 走到 100%,跟页面滚到哪儿一点关系都没有。
滚动驱动动画做的事情,是把进度来源换掉。同一个 @keyframes,进度不再来自时间,而是来自滚动位置:滚到某个位置是 0%,再滚到某个位置是 100%,中间线性插值。
这就带来一个必须提前接受的性质:它是可逆的。你往回滚,动画就倒放,不会”播完就结束”。同时它没有时长概念,写 animation-duration 是没用的,写了也不会报错,但就是不起作用。
CSS 提供了两种时间线:
scroll():把某个滚动容器的滚动位置当作进度。滚动条从最顶到最底,就是 0% 到 100%。view():把自己(或者自己指定的元素)在视口中的可见程度当作进度。元素刚碰到视口下沿是 0%,完全离开视口上沿是 100%。
前者适合”整个页面的进度”,后者适合”这个元素自己的进出场”。
二、案例:一条不占 JS 的阅读进度条
先来最简单的。HTML 只有一个空元素,挂在页面最前面:
<div class="read-progress"></div>
样式部分:
.read-progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
background: #e5484d;
transform-origin: 0 50%;
scale: 0 1;
animation: progress-fill linear both;
animation-timeline: scroll(root block);
}
@keyframes progress-fill {
from { scale: 0 1; }
to { scale: 1 1; }
}
逐行拆一下。
scroll(root block) 的意思是:取根滚动容器(也就是整个文档)在块轴方向上的滚动进度。如果你希望进度条跟随的是某个内部滚动区域,把 root 换成 nearest 或改成命名时间线即可。
动画本身只动 scale,不动 width。原因很实在:scale 走的是合成层,width 会触发布局。一条每帧都在变宽的元素,在低端设备上把主线程拖住是很常见的事。用 scale 的话,进度条变长这件事交给 GPU,主线程基本无感。
但用 scale 就必须配 transform-origin: 0 50%。默认的缩放中心在元素正中,不加这一行,你会看到进度条从中间往两边长,视觉上像个诡异的光标。这个坑几乎每个人第一次都会踩。
另外注意 animation 简写里带了 both。滚动驱动动画强烈建议显式给 animation-fill-mode: both,否则在时间线还没激活的那一小段,属性值可能不按你预期应用,出现”滚到某个位置突然跳一下”的现象。
三、元素入场:view() 和 animation-range
进度条是全局的,元素入场是局部的,这里换 view()。
<section class="reveal">...</section>
.reveal {
animation: rise-in linear both;
animation-timeline: view();
animation-range: entry 10% entry 60%;
}
@keyframes rise-in {
from {
opacity: 0;
translate: 0 60px;
}
to {
opacity: 1;
translate: 0 0;
}
}
view() 把元素自己的可见过程拆成了四个区间,配 animation-range 使用:
cover:从元素下沿刚接触视口下沿,到元素上沿离开视口上沿。这是最长的区间,也是默认值。entry:元素开始进入视口,到元素完全进入视口。exit:元素开始离开视口,到完全离开。contain:元素完全被视口包住的那一段(元素比视口高的情况下这个区间可能是反的)。
上面写的 entry 10% entry 60%,意思是:元素刚进入视口 10% 的时候动画开始,进入 60% 的时候动画完成。这样做的效果是——元素露出一点点的时候还是半透明,等它大部分都进到视口里,已经稳稳落位了。如果直接用默认的 cover,动画会被拉得很长,元素从出现到离开一直在动,看起来反而像卡了。
这里有一个很多人第一次用会疑惑的现象:如果某个 .reveal 元素一进页面就已经在视口里(比如首屏的标题),view() 的进度会直接落在 entry 100% 之后,动画瞬间就是完成态。这正是我们想要的——首屏内容不会因为等待滚动而隐身。所以不要为了”保险”去写额外的 JS 判断。
顺带一提,translate 和 scale 这类独立变换属性现在可以单独写,不用再拼 transform: translateY(60px)。好处是和别的变换互不干扰,你可以在同一个元素上同时用 rotate 和 translate,不会打架。
四、命名时间线:让子元素跟着某个容器滚
前两个例子的时间线是”隐式”的:scroll() 找最近的滚动祖先,view() 找自己。但真实项目里经常需要”让这批子元素跟着这个容器的滚动进度走”,这时候要用命名时间线。
典型场景是一个横向滚动的图片画廊,图片随着横向滚动进程依次放大:
<div class="gallery">
<img class="shot" src="a.jpg" alt="">
<img class="shot" src="b.jpg" alt="">
<img class="shot" src="c.jpg" alt="">
</div>
.gallery {
display: flex;
gap: 16px;
overflow-x: auto;
scroll-timeline: --gallery inline;
}
.gallery .shot {
flex: 0 0 auto;
width: 280px;
animation: pop linear both;
animation-timeline: --gallery;
animation-range: 0% 60%;
}
@keyframes pop {
from {
scale: 0.82;
opacity: 0.35;
}
to {
scale: 1;
opacity: 1;
}
}
关键的两行:滚动容器上用 scroll-timeline: --gallery inline 声明并命名一条时间线,轴向是 inline(横向文字流方向);子元素上用 animation-timeline: --gallery 引用它。
注意这里的 animation-range: 0% 60%,是相对整条横向滚动范围的百分比,不是相对视口。也就是说横向滚到 60% 的位置时,所有图片都完成了动画。如果你希望每张图各自有独立的节奏,就不要用命名滚动时间线,改用 view-timeline 给每个 .shot 命名,或者干脆回到 view()。
命名时间线还有一个容易忽略的限制:它只对声明容器的后代生效。如果动画元素是那个滚动容器的兄弟节点,什么都引用不到,动画会静默失效——不报错,只是不动。这种情况需要 timeline-scope 把名字提升到共同祖先上,属于比较少见但确实会遇到的状况。
五、几个必须提前知道的坑
1. animation 简写会覆盖掉 animation-timeline
这是最容易浪费半小时的一条。下面两行写在同一个规则里,顺序不同,结果完全不同:
/* 正确 */
.a {
animation: rise-in linear both;
animation-timeline: view();
}
/* 错误:简写把 animation-timeline 重置回 auto,动画退回按时间播放 */
.b {
animation-timeline: view();
animation: rise-in linear both;
}
因为 animation 简写会把所有没写出来的子属性重置为初始值,而 animation-timeline 的初始值恰好是 auto。所以永远把 animation-timeline 放在 animation 之后。
2. 时间线必须挂在真正的滚动容器上
给一个 overflow: visible 的元素写 scroll-timeline,等于什么都没发生。滚动容器得有实际溢出内容、有确定的滚动范围,时间线才有进度可言。内容不够长、压根没法滚的时候,进度会是一个固定值,动画就停在某一帧——看起来像”动画坏了”,实际是没得可滚。
3. 别在滚动驱动动画里指望 transition
滚动驱动动画每一帧的值都由滚动位置直接决定,它不是一个”播放过程”。给参与的元素再加 transition,只会让浏览器在每帧插入一个过渡插值,产生拖尾和抖动的观感。想要平滑,调 animation-range 的区间,不要叠 transition。
4. 可访问性:别让动效变成骚扰
滚动驱动的位移和缩放对前庭功能敏感的用户并不友好。记得留一个开关:
@media (prefers-reduced-motion: reduce) {
.reveal,
.gallery .shot {
animation: none;
opacity: 1;
translate: none;
scale: none;
}
}
进度条本身建议保留,它是信息而非装饰,去掉反而损失了功能。
5. 和 position: sticky 组合时注意层叠上下文
一个 position: sticky 的元素如果同时挂了滚动驱动动画,动画产生的 scale 或 opacity 会创建新的层叠上下文,可能让它的吸顶行为和 z-index 表现跟你原本的计算不一样。遇到”sticky 突然不吸顶了”,先看看是不是动画引入的合成层在捣乱。
六、降级:先能用,再好看
浏览器支持这块,Chromium 内核早就默认开启,Safari 在较新版本里也补齐了,Firefox 相对保守一些。所以发布前建议先查一遍当前的支持矩阵,别只看自己机器上的效果。
降级的思路不是”检测到不支持就加一堆 JS 补丁”,而是先保证没有动画也是完整可读的页面,再用 @supports 把动画叠上去。
/* 默认状态:元素正常可见,不依赖任何动画 */
.reveal {
opacity: 1;
}
@supports (animation-timeline: view()) {
.reveal {
animation: rise-in linear both;
animation-timeline: view();
animation-range: entry 10% entry 60%;
}
}
这样写的好处是,不支持的环境下页面完全正常,不会出现”内容全是透明的”这种灾难。很多团队做动画降级翻车,都是因为在基础样式里就写了 opacity: 0,把可见性押在了动画上。
七、什么时候还是该用 JavaScript
滚动驱动动画解决的是”位置到属性”的映射,它不解决逻辑。以下情况老老实实回到 IntersectionObserver 或者动画库:
- 元素进入视口时只触发一次,之后永远不再变(比如埋点、一次性加载)——这本质上是事件,不是动画。
- 动画需要和其他状态联动,比如播放视频、切换音频、更新计数器。
- 需要精确的缓动曲线控制,或者多段编排、时间线串联。
- 需要根据滚动速度做动态响应(CSS 时间线不暴露速度)。
判断标准其实很朴素:如果你要的效果,用一句话就能描述成”滚到 X 位置时属性变成 Y”,那交给 CSS;一旦这句话里出现了”如果””但是””同时还要”,就该考虑 JS 了。
把这些拼起来,一个纯 CSS 的滚动叙事页面已经能覆盖大部分需求了。真正需要注意的是那几条坑:顺序、容器、区间,以及别忘了给不想看动效的人留条路。

