一个 scroll 监听、一层 rAF 节流、一次 getBoundingClientRect,再补上 resize 和横竖屏切换的处理——这套代码在过去七八年里被写了无数遍。麻烦的地方不在于难,而在于它永远长得差不多,却永远要重写一遍,而且每次都要重新想一遍节流阈值该给多少。
现在浏览器把”滚动进度”直接做成了 CSS 能读取的东西。动画的推进不再是时间在推,而是滚动位置在推,计算过程挪到了合成阶段,不用 JS 插手。
滚动时间轴到底替掉了什么
先说清楚它替代的是哪部分工作。传统写法大致是这样:滚动事件触发,拿到 scrollTop,除以可滚动高度得到 0 到 1 之间的进度,再把这个进度换算成 transform 或者透明度写回元素。中间有几个绕不开的问题:
- 在滚动回调里读 getBoundingClientRect 或 offsetTop,会强制浏览器做一次同步布局,滚动帧率很容易掉
- 移动端滚动事件的触发频率和滚动本身不同步,跟手感靠代码补不回来
- 多个效果叠加时,谁先算谁后算、状态怎么存,代码会迅速变脏
滚动驱动动画的思路是把”进度”这个概念原生地交给浏览器:你只描述从 A 状态到 B 状态,浏览器自己去问”现在滚到哪了”。关键属性是 animation-timeline,它把动画的时间来源从时钟换成了滚动位置。
需要记住一条:一旦 animation-timeline 生效,animation-duration 就被忽略。你写的 0.6s 不会起作用,进度完全由滚动决定。这一点在写降级方案时会反复用到。
两种时间轴,先分清楚
scroll():跟着某个滚动容器走
.el {
animation-timeline: scroll(root block);
}
scroll() 接受两个参数,第一个是滚动容器,可选 nearest(默认,最近的可滚动祖先)、root(根滚动容器)、self;第二个是轴,一般用 block,需要横向进度就写 inline 或 x。
这类时间轴的进度只跟滚动位置有关,跟你自己的元素在哪无关。适合做全局性的东西,比如文章阅读进度、顶部导航栏的收缩。
view():跟着元素自己在视口里的位置走
.el {
animation-timeline: view();
}
view() 的进度是”这个元素相对滚动视口走到哪了”。默认覆盖范围是 cover,也就是从元素刚碰到视口边缘开始,到元素完全离开视口结束。适合做入场、离场这类跟元素自身位置强相关的效果。
案例一:顶部阅读进度条
结构简单到只有一行:
<div class="reading-progress" role="progressbar" aria-label="阅读进度"></div>
.reading-progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, #ff5f6d, #ffc371);
transform-origin: 0 50%;
scale: 0 1;
animation: reading-progress linear both;
animation-timeline: scroll(root block);
}
@keyframes reading-progress {
from { scale: 0 1; }
to { scale: 1 1; }
}
几个选择都是有意为之:
用 scale 而不是 width,因为 width 每变一帧就要重新布局一次,而 scale 只走合成,滚动再快也不会掉帧。scale 的两个值是 x 和 y,所以 scale: 0 1 表示横向压扁为零、纵向保持原样。transform-origin 设成左中,进度条才会从左往右长,不然它从中心往两头撑开。
animation 简写里的 both 不能省。没有了它,动画在时间轴两端之外会回到元素的初始状态,页面刚打开还没滚的时候进度条会闪一下。
案例二:卡片从下方淡入
列表页里最常见的那种效果——卡片进入视口时往上浮一点、透明度从 0 到 1。用 view() 时间轴基本是几行的事。
@supports (animation-timeline: view()) {
.card {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 10% entry 60%;
}
}
@keyframes card-in {
from {
opacity: 0;
translate: 0 48px;
}
to {
opacity: 1;
translate: 0 0;
}
}
这里的新面孔是 animation-range。view() 时间轴提供四个有名字的范围:
- cover:元素开始接触视口,到完全离开视口
- entry:元素开始接触视口,到完全进入视口
- exit:元素开始离开视口,到完全离开视口
- contain:元素完全进入视口,到开始离开视口
animation-range 要写两个位置:动画的起点和终点。entry 10% entry 60% 的意思是,从”元素刚进入视口 10%”开始播,播到”进入视口 60%”结束。剩下的 40% 路程元素已经完全不透明了,观感上就是很自然地在进入过程中完成淡入,而不是等到完全进来才动。
有个容易混淆的点:animation-range 里的百分比是相对这个范围自己的长度算的,不是相对整条时间轴。所以 entry 50% 和 cover 50% 完全是两个位置。
另外,卡片如果正好在首屏里、页面加载时就已经完整可见,那它的 entry 进度直接就是 100%,早就超过了 60%,动画稳定停在终态。结果是卡片老老实实显示在那儿,不会闪、不会抽搐。这个行为是对的,不用担心首屏元素需要特殊处理。
案例三:Hero 区域的视差
视差的原理说穿了很朴素:图片比它的容器高,容器裁掉多出来的部分,滚动时让图片在余量范围内上下移动,看起来就有了纵深。
<div class="hero-visual">
<img src="hero.jpg" alt="">
</div>
.hero-visual {
height: 70vh;
overflow: hidden;
}
.hero-visual img {
display: block;
width: 100%;
height: 130%;
object-fit: cover;
animation: hero-parallax linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
@keyframes hero-parallax {
from { translate: 0 -10%; }
to { translate: 0 10%; }
}
图片高度给到容器的 130%,多出来的 30% 就是留给位移的余量。translate 里的百分比是相对元素自身高度算的,130% 的 10% 约等于容器高度的 13%,上下各走 13%,加起来 26%,刚好塞进 30% 的余量里,两边都不会露白。
object-fit: cover 不能省。少了它,图片会被硬拉到 130% 的高度,人脸直接变形。
animation-range 这里用了 cover 0% cover 100%,也就是整个可见周期内位移都在发生,节奏最连续。如果想让它只在元素占据视口主体时移动,换成 contain 0% contain 100% 会更克制一些。
多条动画共用一条时间轴
如果页面上好几个元素都要跟着同一个滚动容器走,给每个元素都写 scroll() 也能跑,但更清晰的做法是先把时间轴命名出来。
.page {
scroll-timeline-name: --page;
scroll-timeline-axis: block;
}
.page__header {
animation: header-shrink linear both;
animation-timeline: --page;
}
命名时间轴默认只在本容器的后代里可见。如果引用方不在这个容器的后代链上(比如它是滚动容器的兄弟节点),就需要用 timeline-scope 把这个名字提升到共同祖先上,否则引用不到,动画会静默失效——不会报错,只是不动,这一点排查起来有点费时间。
降级和减动效
把动画包在 @supports 里是最省心的降级方式。思路是:基础样式写成元素正常可见的样子,只在支持滚动时间轴时再挂动画。
@supports (animation-timeline: view()) {
.card {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 10% entry 60%;
}
}
不支持的时候,.card 就只是一个普通的、可见的卡片。内容完整,没有半透明的鬼影。
减动效处理要单独照顾进度条:
@media (prefers-reduced-motion: reduce) {
.reading-progress { display: none; }
.card { animation: none; opacity: 1; translate: none; }
.hero-visual img { animation: none; translate: none; }
}
.card 和图片本身没有隐藏问题,它们的初始状态就是正常可见的,把动画撤掉就回到静态。进度条不一样,它的基础样式里写着 scale: 0 1,单纯撤掉动画只会让它消失——直接 display: none 反而更合理,一个恒定在动的进度条对前庭敏感的用户本来就不友好。
这段媒体查询要放在文件靠后的位置,和 @supports 里的规则同优先级,靠书写顺序来覆盖。
几个真实会踩到的坑
给固定定位元素加 view() 没意义。position: fixed 的元素相对视口的位置永远不变,view() 时间轴的进度从头到尾都是同一个值,动画等于没写。这类元素要用 scroll()。
overflow: hidden 的容器做不了 scroll() 时间轴。这种容器在规范意义上仍然是可滚动的溢出区域,程序化设置 scrollTop 依然有效,但用户没法用滚轮或手势驱动它。结果就是时间轴进度永远是 0,元素一动不动,而且没有任何报错。
动画属性碰了布局属性,性能优势就没了。滚动驱动动画跑在合成器上是有前提的——只碰 transform、opacity、translate、scale、rotate、filter 这些。一旦动到 width、height、top、margin,每一帧都要重新布局,反而比手写 JS 还难控制,因为你现在没法用节流去救它。
别给 animation-timeline 加 will-change。浏览器已经知道这些元素在参与动画,额外声明只会多占一层合成层的内存。
上线前的检查清单
- 动画只碰合成友好的属性,一次都没碰 width / top / margin
- 动画规则包在 @supports 里,不支持时页面内容照常可见
- @media (prefers-reduced-motion: reduce) 有对应处理,进度条这类纯装饰动画直接隐藏
- 首屏内的元素单独看一眼,确认初始进度落在终态而不是中间值
- 在 DevTools 的 Animations 面板里逐个确认时间轴名字绑定成功,尤其是用了 timeline-scope 的地方
写这篇文章的时候,Chromium 和 Safari 已经默认支持滚动驱动动画,Firefox 的节奏慢一些。所以 @supports 不是可选项,它是这套写法的必备部分。但只要包好,剩下的代码确实比原来短了一大截。
这套 CSS 能替掉的,是那些”读滚动位置、算比例、改样式”的机械劳动。真正需要 JS 的滚动逻辑还在——比如根据滚动位置改变 DOM 结构、驱动 canvas 或 WebGL。但至少进度条和入场渐显这两件事,可以彻底从 JS 文件里删掉了。

