几年前做过一个内容站,页面上有阅读进度条、图片滚到视口时渐显、右侧目录跟着滚动高亮。三样东西拆开写都不难,但凑在一起就是一大堆重复的 scroll 监听加上 getBoundingClientRect 调用。每次滚动都在读布局属性,性能分析里那一条条强制重排的红线看着都难受。
后来我知道了 passive: true,知道了 IntersectionObserver,知道要把读和写拆到 rAF 里。这些确实能治标,但代码量摆在那儿,我每次写都得重新想起来一遍。
滚动驱动动画(Scroll-driven Animations)是把这件事一次性解决的方案:动画的进度不再由时间推进,而是直接由滚动位置决定。你把 keyframes 写出来,剩下的交给浏览器。
先看那个最小的例子
一个阅读进度条,HTML 只需要一行:
<div class="progress"></div>
CSS 是这样的:
.progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
background: #2f6fed;
transform-origin: 0 50%;
animation: grow linear;
animation-timeline: scroll(root block);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
就这样,没有任何 JavaScript。往下滚的时候进度条变长,往上滚变短,滚到页面底部刚好铺满。
这里最值得看的是 animation-timeline: scroll(root block)。这句话的意思是:这个动画的时间轴不是墙上时钟,而是根元素在块轴方向上的滚动进度。滚到 0 的时候动画进度是 0%,滚到底的时候是 100%,中间线性对应。
第一个反直觉的地方:animation-duration 会失效
上面那段 CSS 里我写了 animation: grow linear,没有写时长。如果写成 animation: grow 2s linear,那个 2s 其实是没用的。
原因很简单:时间轴已经不是时间了,时长这个概念也就失去了意义。规范里说得很清楚,滚动驱动动画的时长被当作 auto 处理,animation-delay 同理。你不用去删它,浏览器会忽略。
这也意味着,你没法用 animation-duration 来控制动画”跑多快”。想让它慢一点,靠的是拉长滚动距离,或者用下一节讲的 animation-range 去限制它生效的区间。
顺便说一句,我给进度条用的是 transform: scaleX() 而不是 width。这不只是习惯问题——滚动驱动动画如果作用在 width 这类会触发布局的属性上,每一帧都得走主线程重新算布局。滚动的时候这个开销会非常明显。而 transform 和 opacity 是在合成器线程上跑的,滚动起来完全不掉帧。这一点在滚动驱动动画里比在普通动画里更重要,因为滚动事件本身就很密集。
view() :让元素”滚动到视口时才动”
进度条用的是 scroll(),它跟的是滚动容器。另一个常用的时间轴叫 view(),它跟的是元素自身在视口里的位置。
举个例子,正文里的图片希望是滚到眼前时淡入上来:
.article img {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: none;
}
}
view() 生成的这条时间轴的进度是这样定义的:当元素的顶边刚碰到视口底边时是 0%,当元素的底边越过视口顶边时是 100%。默认范围是 cover,也就是整个从进入到完全离开的过程。
我上面用 animation-range: entry 0% entry 60% 把区间压到前 60% 的入场阶段。意思是图片从刚开始进入视口,到它进了差不多六成的时候,动画就跑完了。这样图片还没滚到画面正中间就已经完全显示出来了,观感上更”稳”一点。如果直接用默认的全区间,图片会在滚出视口的时候反向淡出,那就很怪了。
animation-range 的四个命名区间
这块是新手最容易绕晕的地方,但只要记住 view() 时间轴上有四个命名区间就好理解了:
cover:完整区间,元素从”刚看见一点”到”完全离开”,也就是 0% 到 100%entry:元素从视口底边进入到完全进入视口exit:元素从开始离开视口上边到完全离开contain:元素完全在视口内的那一段(元素比视口高的时候,这个区间可能是空的)
每个名字后面还能带百分比,表示在这个子区间里再切一刀。entry 0% entry 60% 就是”入场阶段的前六成”。
有个坑得特别小心:这些命名区间只对 view() 有效,写在 scroll() 上是无效的。 我给一个滚动容器写 animation-range: entry 0% exit 100% 的时候,浏览器不报错,只是整条声明被判定为无效直接丢弃,动画默默回到默认范围。查了半天才发现是关键字用错了地方。在 scroll() 上你只能用百分比或者具体长度,比如 animation-range: 0% 40% 或者 animation-range: 0 120px。
顶部导航随滚动收缩
这个用 scroll() 加上长度区间最合适。需求是:页面滚过前 120 像素的时候,顶部导航从 72px 高缩到 52px。
.site-header {
position: sticky;
top: 0;
animation: shrink linear both;
animation-timeline: scroll(root block);
animation-range: 0 120px;
}
@keyframes shrink {
from { padding-block: 20px; }
to { padding-block: 10px; }
}
注意这里我动的是 padding,属于触发布局的属性,主线程每一帧都要算。120 像素的区间很短,实际影响可以接受。但如果你要收缩的是一个很复杂、节点很多的头部,最好改成对高度无关的属性下手,比如变一下内部元素的 transform: scale(),或者干脆把收缩效果放在一段背景遮罩上。
另一个细节是 position: sticky 和 position: fixed 的区别。用 scroll() 时间轴去驱动一个 fixed 元素是没问题的,因为固定的只是它的位置,动画进度还是由容器滚动决定。但如果你给一个 fixed 元素配 view() 时间轴,那就是白费力气——它永远钉在一个地方,压根不会在视口里移动,时间轴进度会一直卡在某个值上不动。
给自定义滚动容器加时间轴
上面几个例子的 scroll(root) 都是指文档根节点的滚动。如果滚动发生在一个自己写的容器里,比如一个横向的卡片轮播,就得用命名时间轴了。
<div class="carousel">
<div class="track">
<article class="card">…</article>
<!-- 更多卡片 -->
</div>
</div>
.carousel {
overflow-x: auto;
scroll-timeline-name: --deck;
scroll-timeline-axis: inline;
}
.card {
animation: pop linear both;
animation-timeline: --deck;
animation-range: entry 0% entry 40%;
}
scroll-timeline-name 写在滚动容器上,给这条时间轴起个名字;scroll-timeline-axis: inline 说明我们跟踪的是横向(行内轴)滚动,不写的话默认是块轴(纵向),那这里就完全不动了。
有个查找规则要记住:animation-timeline 引用名字的时候,是沿着元素的祖先链往上找的,找到第一个匹配的滚动容器就用它。所以如果页面上有两个轮播,各自起了不同的名字,就不会互相串。
但如果被动画的元素本身就在滚动容器外面,比如导航区的指示点要跟着轮播走,那就得用 timeline-scope 来扩大这条时间轴的可见范围。
timeline-scope:让”非亲非故”的元素也能用上
这个属性是我觉得整块规范里设计得最巧妙、也最容易被忽略的地方。
假设页面结构是这样的:文章正文和右侧目录是两个并列的兄弟节点,目录里每一项要跟着对应章节的高亮走。问题是,目录项不是章节的后代,按上面的查找规则,它压根看不到章节定义的那条时间轴。
<div class="layout">
<nav class="toc">
<a id="toc-1" href="#sec-1" rel="external nofollow" >第一章</a>
<a id="toc-2" href="#sec-2" rel="external nofollow" >第二章</a>
</nav>
<article>
<section id="sec-1">…</section>
<section id="sec-2">…</section>
</article>
</div>
解决方式是:在共同祖先上声明 timeline-scope,把这些名字”提升”到这个层级,让它的所有后代都能看到。
.layout {
timeline-scope: --sec-1, --sec-2;
}
#sec-1 {
view-timeline-name: --sec-1;
view-timeline-axis: block;
}
#sec-2 {
view-timeline-name: --sec-2;
view-timeline-axis: block;
}
#toc-1 { animation-timeline: --sec-1; }
#toc-2 { animation-timeline: --sec-2; }
规则是这样的:timeline-scope 里列出的名字,可以被声明它的元素的所有后代引用;而这些时间轴的定义元素必须是它的后代。上面这段代码里,#sec-1 是 .layout 的后代,#toc-1 也是,两边都能看到这个名字,链接就建立起来了。
接下来是给目录项加高亮指示条:
#toc-1::before,
#toc-2::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: #2f6fed;
transform-origin: 0 50%;
animation: mark linear both;
}
#toc-1::before { animation-timeline: --sec-1; animation-range: entry 30% exit 70%; }
#toc-2::before { animation-timeline: --sec-2; animation-range: entry 30% exit 70%; }
@keyframes mark {
0% { transform: scaleY(0); }
12% { transform: scaleY(1); }
88% { transform: scaleY(1); }
100% { transform: scaleY(0); }
}
这里用的是 keyframe 里的”平台期”技巧:动画两头分别是 0% 和 100% 的状态,中间 12% 到 88% 保持满值。配合 entry 30% exit 70% 的区间,效果就是”章节进入视口一段之后高亮亮起,快离开时淡出”。
说实话,这个方案只能算近似。因为每条时间轴的范围是章节自身的高度决定的,短章节和长章节跑完动画的时机不一样,各个百分比需要你手动调。真正要做到”当前阅读到哪一段就高亮哪一条”,还是得靠滚动偏移量来算。但对于大部分长篇内容来说,调一调就够了,而且调完之后页面上没有任何 JS 在监听滚动。
降级:不能装作没这回事
浏览器支持情况大概是这样的:Chrome 从 115 开始就有了,Edge 跟得很紧,Safari 到 26 才补齐。Firefox 到现在还在实现中,默认不开启。
也就是说,如果你今天直接上线,Firefox 用户看到的会是:图片完全不显示(因为 opacity: 0 是写在 keyframes 的 from 里,动画不跑,元素就停在那个初始态)。这是个很严重的翻车方式。
所以务必留一层保护。最省事的是用 @supports 把整套动画包起来,默认样式写成”已经是最终状态”:
.article img {
opacity: 1;
transform: none;
}
@supports (animation-timeline: view()) {
.article img {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
这样不支持的时候,图片就是普通地正常显示,功能一点不丢。支持的时候才享受那个渐显效果。
如果渐显本身是产品的核心体验,那还得补一段 IntersectionObserver 的 fallback。写起来也不长,核心思路是同样的效果,只是由观察器来加类名:
if (!CSS.supports('animation-timeline', 'view()')) {
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-revealed');
io.unobserve(e.target);
}
}
},
{ rootMargin: '0px 0px -15% 0px' }
);
document.querySelectorAll('.article img').forEach(el => io.observe(el));
}
然后给 .is-revealed 写一个普通的 CSS transition 就完事了。两套机制互不干扰。
顺带一提,prefers-reduced-motion 也得处理。虽然浏览器不会帮你自动关掉滚动驱动动画(它不像跨文档视图过渡那样自带这个逻辑),但你该自己加:
@media (prefers-reduced-motion: reduce) {
* {
animation-timeline: auto !important;
}
}
把时间轴改回 auto,动画就会退回成基于时长的普通动画。因为大多数这类动画的时长设置本来就没写,实际效果就是动画几乎不体现,元素直接停在最终状态。这个降级算干净。
调试的时候怎么下手
这块的调试体验做得不错。Chrome DevTools 里打开 Animations 面板,页面上每个滚动驱动动画都会列出来,点进去你能手动拖动进度滑块,直接看某个滚动位置对应的动画状态。
更实用的是,在 Elements 面板里选中那个元素,右侧 Styles 里 animation-timeline 那一行会显示一个小的”滚动条图标”,点开能看到时间轴的分区示意图——entry、contain、exit 这几段用不同颜色标出来。当你搞不清 animation-range 到底落在了哪一段,这个图能省很多时间。
还有一个技巧:临时给容器加个 outline: 1px solid red,滚动的时候你就知道动画到底跟的是哪个容器了。用错滚动容器的坑我踩过不止一次,尤其是页面上同时存在 html 滚动和某个局部容器滚动的时候。
几个必踩的坑
动画元素不能有 display: none 的祖先。 元素不渲染的话,它就没有几何信息,view() 时间轴算不出来,进度一直是 0。一个 tab 面板里藏着的内容切过去之后动画不对,多半是这个原因。
content-visibility: auto 要小心。 它会让视口外的内容跳过渲染,虽然能省性能,但和 view() 时间轴配在一起的时候,几何计算可能不准。两个特性都想要的话,测试的时候多滚几遍看看。
别指望 animation-fill-mode 帮你兜底。 我一开始以为只在支持的时候才写动画、不支持就不写就行,但 keyframes 里的初始值依然会在不支持的环境里生效(如果动画属性被部分解析的话)。最稳的还是把最终状态写在基础样式里,动画只负责”从某个偏移值过渡过来”。
时间轴名字和 CSS 自定义属性会打架。 如果你给一个容器同时写了 --deck: something 和 scroll-timeline-name: --deck,那是两个命名空间,不会冲突,但读代码的人会非常困惑。命名上建议分开,比如时间轴统一用 --t- 前缀。
滚动容器得有实际可滚动的距离。 如果内容刚好铺满,没有溢出,滚动范围是 0,整条时间轴就退化成一个点,动画永远卡在同一帧。这个在响应式布局里特别容易碰到——大屏幕上不溢出,动画就”失灵”了。写的时候想一想内容不足时的表现。
最后说两句
滚动驱动动画最打动我的地方,其实不是”省了多少行 JS”,而是它把一件原本含糊不清的事情表达清楚了。
以前写滚动效果,代码是这样描述的:”监听到滚动事件 → 算一下位置 → 手动设置样式”。你读代码的时候,得在脑子里把这三步还原成一个连续的过程,才能明白最终效果是什么。
现在是这样描述的:”这个动画的时间轴是页面滚动,生效区间是前 120 像素”。声明式的表达把”意图”直接写出来了,效果是什么样,读一眼就知道。
而且因为它跑在合成器上,不会因为主线程忙就卡顿,也不需要在滚动里做节流那一堆取舍。如果你的项目里现在还挂着一堆 scroll 监听,值得挑一个场景换成这套写法试试,哪怕只是先做那个进度条。

