上个月改一个文档阅读页面。这个页面的顶部有一根阅读进度条,随着用户往下滚,进度条从左到右填满。旁边还挂着一个章节导航,滚动到某一章的时候对应项要”亮起来”。页面底部有一句”已经到底了”的提示,快滑到底的时候淡入。
这套交互最早的实现是用 scroll 事件做的。一份主文件里散落着四个监听器,每次滚动都要读 scrollTop、算一次百分比、再改一遍 DOM 样式。为了怕抖动,作者还额外加了 requestAnimationFrame 去做节流。整个逻辑凑起来大概两百多行。
能跑,但有几个问题一直没解决。一是滚动的时候主线程稍微一忙,进度条就会轻微地卡一下,能明显感觉到不跟手。二是节流写的是 60fps,但用户滚动速度快的时候仍然会出现跳变,看起来不连续。三是移动端在惯性滚动阶段,事件的触发频率和滚动位置经常对不上,进度条的末端会提前卡住。
CSS 的滚动驱动动画(scroll-driven animations)在 Chrome 从 115 版本开始正式落地之后,一直在我待办清单上躺着。这次终于找到一个可以放心试的场景,把这段逻辑完整换成了 CSS。
先把概念说清楚
普通 CSS 动画的进度由时间驱动:animation-timeline: auto 意味着动画在 animation-duration 指定的时间里跑完。
滚动驱动动画把”时间”换成了”滚动位置”。也就是动画的进度由滚动条的位置决定,滚到哪动画跑到哪,不滚动画就停着。这听起来简单,但它保证了一件在 JS 方案里很难做好的事情:动画进度和滚动位置严格同步,同一帧渲染。
这个同步性是关键。JS 方案下,滚动事件触发 → 回调执行 → 样式更新 → 下一帧渲染,中间隔着好几步,任何一步的延迟都会体现在进度条上。CSS 方案下,滚动和动画在合成线程里一起完成,不会因为主线程忙而掉队。
CSS 提供了两种滚动时间线:
scroll():绑定到最近可滚动的祖先容器(或者指定的命名容器),进度从滚动开始到滚动结束。view():绑定到元素自身进入和离开视口的过程。元素刚进入视口时进度 0,完全离开时进度 100%。
两种时间线覆盖了 90% 的场景。scroll() 用于”整体进度”类,比如阅读进度条;view() 用于”单个元素出入场”类,比如淡入、放大、位移。
案例一:把阅读进度条换掉
原来的结构大概是这样:
<div class="progress-track">
<div class="progress-bar" id="bar"></div>
</div>
<article class="post">
... 很长的正文 ...
</article>
新写法不需要改 HTML:
.progress-track {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: rgb(15 23 42 / 0.06);
z-index: 100;
}
.progress-bar {
height: 100%;
transform-origin: left center;
scale: 0 1; /* 起点是宽度为 0 */
background: #3b82f6;
animation: progress-fill linear forwards;
animation-timeline: scroll(root);
}
@keyframes progress-fill {
to {
scale: 1 1;
}
}
scroll(root) 表示绑定到文档根滚动,是最常见的形式。整段代码没有一行 JS。
这里我用了 scale 而不是 width。原因很简单:width 会触发重排,scale 不会。虽然滚动驱动动画本身已经在合成线程里跑,但把绘制成本先压下来总没坏处。
另外一个容易忽略的细节是 transform-origin。默认是 50% 50%,也就是从中心缩放,进度条会从中间往两边展开,看起来很奇怪。改成 left center 才是想要的效果。
跑起来之后最直观的感受是:进度条和滚动条完全同步了。停在哪个位置,进度就走到哪,非常精确。用鼠标滚轮一格一格地滑,进度条一格一格地推,手感上像换了一个应用。
案例二:章节导航的高亮
原来高亮逻辑是用 IntersectionObserver 做的:监听每个章节的标题元素,进入视口就切换高亮。这套方案本身没问题,但它的高亮是”跳变”的,某章一进入视口整条导航就闪一下,切到下一章再闪一下。
用 view() 时间线可以做出一个更顺滑的效果,同时高亮和滚动本身完全同步:
.toc li {
position: relative;
padding-left: 12px;
color: #64748b;
}
.toc li::before {
content: "";
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 2px;
background: #3b82f6;
opacity: 0;
scale: 1 0.4;
transform-origin: center;
animation: toc-active linear both;
animation-timeline: view();
animation-range: entry 20% cover 60%;
}
@keyframes toc-active {
0% {
opacity: 0;
scale: 1 0.4;
}
30%, 70% {
opacity: 1;
scale: 1 1;
}
100% {
opacity: 0;
scale: 1 0.4;
}
}
这段 CSS 里面 animation-range 是关键。view() 默认的时间范围是元素”从刚进入视口到完全离开视口”,这个范围太宽了,用在高亮切换上会觉得指示条闪烁的时间过长。entry 20% cover 60% 的意思是:
entry 20%:从元素进入视口 20% 的位置开始cover 60%:到元素离开视口 60% 的位置结束
这样指示条在元素刚露头的时候开始淡入,在元素挡住视口大部分的时候最亮,等元素滑出视口时再淡出。视觉上很自然。
animation-range 的取值有好几种形式,常用的有:
entry:元素从视口边缘进入,到完全进入视口exit:元素从完全离开视口,到滑出视口边缘cover:元素从进入视口边缘,到离开视口边缘(这是默认的全过程)contain:元素完整可见的那一段
这些关键字后面可以跟百分比来指定具体位置,还有 entry-crossing、exit-crossing 这样的精确点。实际操作里最常用的就是 entry、exit 和 cover 三组关键字。
案例三:卡片滚进视口的入场动画
以前做卡片入场都是这么搞的:初始状态设 opacity: 0、translateY(20px),然后用 IntersectionObserver 检测到进入视口就加一个 class 触发 transition。这套方案的缺点是所有卡片的行为一致,第一个进入视口的卡片和最后一个距离滑入的距离一样,看起来有点机械。
用 view() 可以让每张卡片根据自己的位置”飘”进来:
.card {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 10% entry 80%;
}
@keyframes card-in {
from {
opacity: 0;
translate: 0 32px;
scale: 0.96;
}
to {
opacity: 1;
translate: 0 0;
scale: 1;
}
}
entry 10% entry 80% 的意思是:卡片刚进入视口 10% 的时候开始动画,进入 80% 的时候动画结束。也就是说,卡片需要滚到比较深入视口的位置才完全显示出来。视觉上就是”慢慢浮现”的感觉。
这里有一个坑:没设置 animation-range 的话,默认用的是全范围(等价于 cover),动画会在卡片还没进入视口的时候就跑起来。因为元素在视口下方很远的地方其实已经和视口有交集了,只不过交集是”完全在下方”这种状态。所以每个入场动画几乎都要写 animation-range。
另一个坑是 both 这个关键字。它表示动画在时间线之外也保持最终状态(forwards)和初始状态(backwards)。如果只写 forwards,卡片在进入视口之前会短暂显示为正常状态(也就是 opacity 1),闪一下才开始淡入,看起来很突兀。滚动驱动动画的入场场景一定要用 both,这是最容易踩的坑之一。
案例四:横向滚动画廊的进度指示
页面里有一个横向滚动的产品画廊,用户滑动的时候下面的缩略图指示条也要跟着走。以前这个用 JS 单独算了一遍 scrollLeft / (scrollWidth - clientWidth),然后转成百分比给指示条。
现在直接把指示条挂到画廊的滚动时间线上:
.gallery {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
/* 给这个滚动容器一个名字 */
scroll-timeline-name: --gallery-x;
scroll-timeline-axis: x;
}
.gallery-indicator-bar {
height: 3px;
background: #3b82f6;
transform-origin: left center;
scale: 0 1;
animation: gallery-progress linear forwards;
animation-timeline: --gallery-x;
}
@keyframes gallery-progress {
to {
scale: 1 1;
}
}
这里用了 scroll-timeline-name 来命名一个时间线,让非祖先元素也能引用它。当滚动容器和使用方不是父子关系的时候(比如指示条在画廊外面),就必须用命名时间线。
命名时间线有一个作用域的概念。默认情况下,命名的作用域是”它所在的元素及其后代”。如果指示条和画廊不在同一个祖先下,就得在两者共同的父元素上加 timeline-scope:
.gallery-wrapper {
timeline-scope: --gallery-x;
}
这个名字没对上,动画会直接静默失效,找不到原因的时候最容易怀疑是浏览器不支持。我在这个上面花了将近一个小时,才发现是 timeline-scope 忘了写。
降级方案不能省
滚动驱动动画在 Chrome、Edge 上是完全可用的,Safari 和 Firefox 从今年的版本开始也在陆续跟进。但在不支持的浏览器上,这段 CSS 会直接失效——动画不会跑,元素最终停在初始状态。对于进度条这种初始 scale: 0 1 的元素来说,就等于永久隐藏了。
降级方案推荐用 @supports 判断:
@supports (animation-timeline: scroll()) {
.progress-bar {
scale: 0 1;
animation: progress-fill linear forwards;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.progress-bar {
/* 老浏览器里退回原始宽度,不显示动态进度 */
scale: 1 1;
opacity: 0.6;
}
}
这样在不支持的浏览器上,进度条退化成一条静态的半透明装饰,页面是可读的、不会破相。等浏览器普及到一定程度,再把这个降级分支删掉。
如果你希望在不支持的浏览器上也保留真正的进度条,那就得写一份 JS 兜底。我实际的写法是把 JS 版和 CSS 版拆成两个文件,用同一个 @supports 判断来动态 import。CSS 支持的话就不加载 JS 那个模块,省掉一次网络请求和解析成本。
几个值得提前知道的细节
滚动驱动动画和 prefers-reduced-motion。 这一点经常被忽略,但很重要。对于有前庭功能敏感的用户,滚动相关的持续动画可能引起不适。推荐在动画外层套一层媒体查询:
@media (prefers-reduced-motion: reduce) {
.progress-bar,
.card,
.toc li::before {
animation: none;
}
.progress-bar {
scale: 1 1;
opacity: 0.5;
}
}
时间线不是万能的。 scroll() 只有一个时间线,它表达的是”整个滚动容器从开始到结束”。如果你需要”元素 XX 滚到 XX 位置”这种精确触发,它做不到。这种就得用 view() 或者干脆用 ScrollTimeline JavaScript API 手动构造时间线。
view() 的祖先选择器有讲究。 默认情况下,view() 观察的是最近的可滚动祖先。如果元素的祖先里有多个滚动容器(比如页面本身可滚,里面还有一个可以横向滚的卡片列表),行为可能和你想的不一样。想明确指定观察哪个滚动祖先,可以用 view(--my-scroller) 引用命名时间线。
调试工具。 Chrome DevTools 里在 Elements 面板选中一个使用滚动驱动动画的元素,右侧的 Styles 面板会看到动画旁边多了一个小圆圈图标,点开可以看动画时间线详情。Animations 面板里也能把滚动时间线当成一条时间轴来拖拽预览,调 animation-range 的时候特别好用。
改造完的结果
整个文档阅读页面改完之后:
- 删掉了 4 个 JS 监听器和一段 rAF 节流逻辑,一共 210 多行
- 滚动时进度条的帧率从
48fps左右稳定到60fps满帧 - Mid-range Android 设备上滚动卡顿率下降了一半以上
- 移动端的惯性滚动阶段,进度条位置和手指位置始终对齐,不再出现尾部卡顿
我最想强调的其实不是性能指标。是那段”事件监听 + 节流 + 手动算百分比”的代码没了。以前维护这段逻辑,得考虑”什么时候需要重新绑定监听”、”移动端怎么处理触摸滚动和惯性滚动”、”SSR 场景下怎么处理初始状态”这些问题。现在这些全都不需要了。代码变少,出问题的面也跟着变少。
什么场景不要用滚动驱动动画
用了几个案例之后,也总结出几个不值得用的场景,分享出来省点弯路。
第一,动画需要和滚动位置解耦的时候。 比如”滚到页面 80% 的位置弹出一个订阅框”,这种和”位置”绑定、但不需要”随时变化”的场景,用 IntersectionObserver 更合适。滚动驱动动画的本质是”随时映射”,用在一次性触发上是大材小用,还给合成线程增加负担。
第二,需要根据滚动方向做不同动画的时候。 scroll() 和 view() 都是和位置绑定的,往下滚和往上滚,只要位置一样,动画进度就一样。如果要区分方向,还得靠 JS。
第三,需要精确控制”惯性滚动结束后”触发的时候。 滚动驱动动画在滚动过程中一直在跑,无法区分”用户正在拖”还是”惯性滑动”。这类场景还是 scrollend 事件加一点 JS 更靠谱。
沿用我改这个页面时的一个判断:如果一段 JS 是为了”让样式跟上滚动位置”,那基本就可以用滚动驱动动画替换;如果它包含”判断、决策、状态机”这类逻辑,那还是留在 JS 里。
写在最后
CSS 这两年更新的很多特性,路线其实是一致的:把过去”必须用 JS 做”的事情挪回声明式。滚动驱动动画、锚点定位、容器查询、:has() 选择器,都是同一个方向上的东西。
这个方向对前端工程师来说,意味着要学的东西变得更多了——过去一个滚动进度条就是几行 scrollTop 除法,现在要理解时间线、作用域、动画范围、降级策略这一整套概念。但一旦学会了,写出来的代码会更短、更稳、更不容易出 bug。
如果你手上也有那种”能用但总觉得有点卡”的滚动交互,可以拿一个最简单的场景先试试。改一个进度条、或者改一组入场动画,感受一下那种”和滚动完全同步”的感觉。你会发现这种”顺”是以前靠 JS 节流再怎么优化也做不出来的。

