CSS Scroll-driven Animations:纯 CSS 实现滚动驱动动画

本文深入解析 CSS Scroll-driven Animations 规范,介绍 scroll() 与 view() 时间线用法、性能优势、常见踩坑和兼容性策略,并通过阅读进度条等示例帮助你判断是否该在真实项目中采用纯 CSS 滚动驱动动画。

滚动驱动动画到底解决了什么问题

滚动驱动动画,通俗点说,就是让“用户滚动了多少”直接决定动画走了多少。过去这件事几乎离不开 JavaScript:监听 scroll 事件、读取滚动位置、计算元素与视口的相交比例,再通过 requestAnimationFrame 更新样式。方案本身没什么问题,只是当一个页面里的滚动交互动画变多时,这类代码会带来两个麻烦:一是逻辑分散,每个动画都要单独维护一段计算;二是高频滚动回调很容易触发大量样式更新,在低端设备上尤其明显。

AI technology illustration

CSS Scroll-driven Animations 这套规范要做的,就是把“滚动进度”变成一种可以被动画直接引用的时间线。换句话说,我们不再需要手动把滚动位置换算成进度,再反过来驱动 CSS 属性。浏览器会观察滚动容器或某个元素进入视口的变化,并像 timeline 一样把它喂给 animation-timeline。这篇文章会围绕它展开,说清楚它是怎么工作的、实际项目里怎么用,以及为什么不是所有滚动动画都适合迁移到纯 CSS 实现。

过去用 JavaScript 是怎么写的

拿最常见的阅读进度条举例,很多团队是这样实现的:监听 document 的 scroll 事件,在回调里计算 scrollTop 和 scrollHeight 的比值,得到一个百分比,再写进 CSS 变量或者设置进度条的 transform。单看逻辑很简单,但一旦页面里加入图片懒加载、折叠组件,页面高度会在滚动中不断变化,之前算好的进度突然就不准了。接下来还要处理 resize、移动端地址栏的显示隐藏,这部分代码最低也要几十行。

如果只是进度条还好,当页面里的滚动动画数量上升,问题会更明显。每个组件都要自己监听滚动、计算各自的位置,事件回调频繁触发,很多计算其实在做重复功。即使借助 IntersectionObserver,也只能得到“是否进入视口”,很难精确控制动画的连续进度——特别是元素滚动到一半的时候。

所以大家自然会想:有没有一种更底层的机制,把滚动进度直接绑定到 animation timeline 上。这正是 CSS Scroll-driven Animations 出现的原因。

scroll() 和 view():两条核心时间线

这套规范里最常用的两条时间线是滚动进度时间线和视图进度时间线。

  • 滚动进度时间线(Scroll Progress Timeline):和某一个滚动容器绑定,进度从容器滚动起点走到终点,常用 scroll() 表示。
  • 视图进度时间线(View Progress Timeline):和某一个元素绑定,进度表示元素在滚动容器中进入和离开视口的程度,常用 view() 表示。

先看 scroll() 的典型写法。假设要做一条文章阅读进度条,底部横线根据滚动进度从 0 拉伸到 100%:

.article-progress {
  transform-origin: left center;
  animation: fill-progress linear both;
  animation-timeline: scroll(root block);
}

@keyframes fill-progress {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

scroll(root block) 表示使用根滚动容器的 block 方向作为时间线。block 是逻辑方向,对水平书写模式来说就是垂直方向。这里的 root 也可以改成其他滚动容器的具名引用,inline 对应水平方向。

view() 的用法更贴近“元素出现时触发动画”的场景。比如希望一个元素从进入视口到滚过一半时完成透明度和位移的变化:

.reveal {
  animation: fade-up linear both;
  animation-timeline: view(block);
  animation-range: entry 0% entry 50%;
}

@keyframes fade-up {
  from { opacity: 0; transform: translateY(40px); }
  to { opacity: 1; transform: none; }
}

view(block) 告诉浏览器,以该元素自身在视口中的可见过程作为动画时间线。animation-range 用来裁剪动画的起止阶段:entry 表示元素开始进入视口,exit 表示完全离开视口。相比手动读取 offsetTop 和视口高度再计算进度,这个写法显然更接近语义本身。

为什么它在某些场景下更流畅

很多文章会强调“纯 CSS 滚动动画性能好”,但这句话需要拆开看。它好在哪?浏览器可以把滚动驱动动画交给合成器线程处理,如果动画只修改 transform 或 opacity 这类合成器属性,滚动过程中主线程可以不参与。对比之下,JavaScript 监听 scroll 事件即使做了节流,也依然会在每次触发时执行读取和写入,容易造成布局抖动。

不过,如果你把 animation 的关键帧写成修改 height、top、margin 这类属性,合成器也帮不了你,主线程照常参与布局计算。CSS Scroll-driven Animations 并没有改变“动画属性必须谨慎选择”这个原则。

一个典型的场景是运营活动页:产品图或装饰元素随着页面滚动从旋转状态回到直立状态。过去用 JS 实现时,需要在 scroll 回调中不断计算元素相对文档的偏移量,再换算成旋转角度。如果页面里还有嵌套滚动容器,逻辑会变得特别容易出错。换成 view() 之后,动画和多层滚动脱钩,代码量可以压缩到几行关键帧。

scroll()、view() 和具名时间线的选择

下表是几条时间线在工程视角的差异:

时间线 进度来源 典型场景 注意事项
scroll() 滚动容器的整体滚动进度 阅读进度条、返回顶部按钮动画 默认观察最近滚动容器,注意嵌套
view() 元素相对视口的可见进度 进入视口淡入、滚动过程中的位移动画 animation-range 需要理解 entry/cover/exit
具名时间线 由 scroll-timeline 显式绑定容器 非根滚动容器、多个滚动区域共用一个动画 需要额外命名,但可读性更明确

大多数情况下,我会优先尝试 scroll() 和 view()。直到页面里出现两个以上的滚动容器,或者需要在某个容器内部驱动范围动画时,才会换成具名时间线,例如:

.page-scroller {
  scroll-timeline: page-timeline block;
}

.progress-bar {
  animation-timeline: page-timeline;
}

具名时间线的优势是意图清晰,尤其在结构复杂的页面里,后续维护的人一眼就能看出动画观察的是哪个容器。

容易踩到的几个坑

这个特性真正进入生产环境的时间还不长,支持情况和使用细节都需要注意。

兼容性比想象中窄

Safari 目前对这套规范支持有限,Firefox 也像往常一样要先在 about:config 里开设置。如果你所在项目的目标用户大多是 Chromium 内核,可以大胆尝试;否则建议把滚动动画当作渐进增强,而不是唯一的内容展示方案。常用写法是 @supports (animation-timeline: scroll()) 包一层,不支持时元素保持原始状态。

滚动容器判断错误

scroll() 会默认使用最近的滚动容器。页面里一旦出现 overflow-x: hidden 或某个滚动的 wrapper,就可能让时间线的参考容器不是你以为的那个。这时用具名时间线反而更安全,毕竟显式绑定不会依赖“最近容器”的规则。

range 写错,动画迟迟不触发

view() 的 animation-range 有 entry、cover、exit 几个关键阶段。不少人把范围写成 entry 0% exit 100%,发现动画行为和自己想的不一样。建议先在浏览器开发者工具里打开 Animations 面板,调整 range 观察时间线进度,再回到代码里固定参数。

动效和偏好设置

滚动驱动动画仍然是动画,对 prefers-reduced-motion 的适配不能省。可以在全局媒体查询里关闭大位移动画,至少保留一种静态呈现。

什么时候别用纯 CSS 方案

不要为了“去 JS”而把业务逻辑塞进动画。CSS 滚动驱动动画是无副作用的:它只负责视觉表现,不会告诉你当前进度是多少。如果滚动进度需要上报埋点、切换正在展示的文案、或者联动图表的数据变化,仍然需要 JavaScript 主动监听 scroll —— 这时纯 CSS 只能作为其中的“渲染层”。

以下几种情况,我建议继续使用 JS:

  • 动画触发后需要改变 DOM 结构或异步加载数据。
  • 需要把当前进度值同步给第三方库或图表。
  • 动画的缓动依赖另一个动画的中间状态。
  • 项目必须兼容 Safari 全版本,且滚动动画是核心体验。

这个判断标准不是“能不能用 CSS”,而是“滚动进度是否需要进入业务逻辑”。

如何在一个已经上线的项目里引入

我的建议是从一个独立且低风险的组件开始,比如顶部阅读进度条。步骤如下:

  1. 先用普通 animation 写好关键帧,并用 animation-play-state: paused 冻结在初始状态,作为不支持时的兜底。
  2. 在 @supports 里补上 animation-timeline 和 scroll() 的调用。
  3. 打开开发者工具的 Animations 面板,确认时间线是否随滚动正确推进。
  4. 在低端设备上滚动测试,注意观察 transform 和 opacity 是否处于高性能路径。

一段兜底写法示意:

.progress {
  animation: fill-progress 1s ease-out both;
  animation-play-state: paused;
}

@supports (animation-timeline: scroll()) {
  .progress {
    animation-play-state: running;
    animation-timeline: scroll(root block);
  }
}

这段代码的核心思路是:不支持时,进度条只是静止在某个状态;支持时,把动画时间线换成滚动容器,用户滚动时它才开始响应。

CSS Scroll-driven Animations 没有把 JavaScript 从项目里赶走,但它确实让“滚动 + 动画”这一类功能回归到了描述层。我们不再需要为了一个淡入效果写三四十行 offsetTop 计算,只需要告诉浏览器:观察这个元素在视口里的可见过程,然后执行这几帧动画。

理解 scroll() 和 view() 的区别,并且清楚自己的滚动容器是谁,是使用这套特性的第一步。下一步则是想清楚动画失败时的降级策略。纯 CSS 不是魔法,它只是把一部分底层工作交给了更擅长做这件事的浏览器引擎。真正怎么写,仍然取决于你的页面规模和兼容性要求。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/798/

(0)
上一篇 1天前
下一篇 20小时前

相关推荐