大概半年前,一个内容型的博客项目需要加一个顶部阅读进度条,同时每个段落进入视口时要有一个轻微的淡入效果。按老套路,我二话不说用 `scroll` 事件监听加 `Intersection Observer` 写了三四十行 JavaScript。功能正常,但每次滚动都触发事件计算,低端手机上偶尔掉帧,而且为了同步进度条和视口动画,代码里还掺了一个 `requestAnimationFrame` 包裹的节流逻辑。后来在 Chrome 开发者博客上看到关于 CSS 滚动驱动动画的介绍,用一个 `animation-timeline: scroll()` 直接把进度条绑定到滚动位置,干脆利落,无需一丝 JavaScript。趁着最近这个特性已经覆盖主流浏览器,我把博客项目里的那堆滚动相关脚本全部换成了纯 CSS 方案。这篇文章就通过这两个真实的案例,完整拆解 CSS 滚动驱动动画到底怎么用,以及它和传统写法相比到底好在哪里。
一、CSS 滚动驱动动画解决了什么问题
以前想实现滚动相关的动画,脑子里蹦出来的第一个念头就是监听 `scroll` 事件,然后根据 `window.scrollY` 或者元素相对于视口的位置去计算动画进度。这种方式的根本问题是:动画的执行和浏览器的渲染没有同步。`scroll` 事件是异步的,且频率可能因为性能优化而被延迟,导致动画与滚动脱节,也就是常说的“粘滞感”。如果用 `requestAnimationFrame` 手动去读取滚动位置,还得处理大量的数值映射和缓动。
CSS 滚动驱动动画通过 `animation-timeline` 属性,允许直接将滚动容器的滚动偏移量或者元素在视口中的可见比例作为动画的时间线。动画的 `@keyframes` 不再绑定到秒或毫秒,而是绑定到滚动进度。当用户滚动时,浏览器在合成线程上直接驱动动画,天然同步,不会出现 main thread 阻塞导致的卡顿。
目前主要提供了两种时间线来源:
scroll()— 基于滚动容器的滚动偏移量。可以绑定到最近的滚动祖先或根元素。view()— 基于元素相对于视口的可见进度。非常适合做元素进出视口时的入场或出场动画。
我们先从最直观的阅读进度条开始,再过渡到段落动画。
二、阅读进度条:用 scroll(root) 告别事件监听
阅读进度条的需求很简单:一个固定在页面顶部的横条,宽度从 0% 到 100% 反映用户滚动了文档的多少比例。旧的实现通常是这样:
window.addEventListener('scroll', () => {
const scrollTop = document.documentElement.scrollTop;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = (scrollTop / docHeight) * 100;
progressBar.style.width = progress + '%';
});
用 CSS 滚动驱动动画,我们可以把这个逻辑彻底移到样式表中。创建一个固定在顶部的 `
HTML 结构只需要一个进度条元素:
<div class="reading-progress"></div>
然后是核心的 CSS:
.reading-progress {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(90deg, #4f46e5, #a855f7);
/* 声明动画:从宽度0到宽度100% */
animation: progress linear;
/* 时间线绑定到根滚动轴,滚动沿着块轴(垂直方向) */
animation-timeline: scroll(root);
/* 动画的开始和结束对应滚动0%到滚动100% */
animation-range: 0% 100%;
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}
这里的关键属性是 `animation-timeline: scroll(root)`。它告诉浏览器:不要用时间来驱动这个动画,而是用根滚动容器在块轴(即垂直滚动条)上的滚动进度。`animation-range: 0% 100%` 进一步明确了映射关系:当滚动处于 0% 位置时,动画执行到 `from` 帧;滚动到 100% 时,动画执行到 `to` 帧。中间的数值由浏览器自动插值,用户滚多快,动画就走多快,完全跟随手指或滚轮。
这个进度条的性能表现显著优于 JavaScript 方案。用开发者工具的性能面板对比,原来的滚动事件会持续在主线程触发重排和重绘;而 CSS 驱动的动画完全在合成器线程运行,不影响主线程,滚动过程平滑流畅,基本没有掉帧。
三、段落淡入动画:用 view() 实现进入视口即触发
接下来是第二个案例:每个段落从视口下方进入时,从透明渐显并轻微上移。以前用 `Intersection Observer` 的实现方式需要给每个段落分配一个观察器,还要处理 visibility 状态切换。
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
});
document.querySelectorAll('p').forEach(p => observer.observe(p));
现在用 `view()` 时间线,可以直接在 CSS 中对任意元素设定它的可见性触发动画。`view()` 会跟踪元素自身穿过视口的过程,动画进度与元素在视口内出现的比例挂钩。
给每个段落定义一个淡入上移动画:
p {
/* 初始状态: 透明且下移一点 */
opacity: 0;
transform: translateY(20px);
animation: fade-in linear;
animation-timeline: view();
/* 动画从元素即将进入视口时开始,到完全进入视口时结束 */
animation-range: entry 0% entry 100%;
}
@keyframes fade-in {
to {
opacity: 1;
transform: translateY(0);
}
}
`animation-timeline: view()` 让浏览器监控该元素相对于滚动视口的交叉比例。`animation-range` 的 `entry 0%` 表示当元素刚接触视口下边缘时动画开始,`entry 100%` 表示当元素完全进入视口(下边缘刚好滚动到元素顶部)时动画结束。这样当用户往下滚动,元素从底部露出到完全进入视野的过程中,透明度从 0 变为 1,同时向上移动复位,非常自然地实现了一个入场过渡。
也可以精细控制范围,例如让动画更早开始,使用 `contain 0% contain 100%` 将动画限定在元素完全被视口包含的期间内。甚至可以用 `exit` 关键字做退出动画:元素离开视口时触发反向动画。所有这些调整都遵循同样的时间线映射思想,非常灵活。
四、进一步优化:同时应用多个动画与性能考量
在实际项目中,进度条和段落动画可以共存而不冲突。每个动画都拥有独立的时间线和关键帧,互不干扰。甚至可以在同一个元素上绑定多个动画——比如一个元素既有基于 `scroll(root)` 的进度条,又有基于 `view()` 的缩放入场。语法上,`animation-timeline` 属性可以接受多个值,对应 `animation` 列表中的每个动画。
我曾尝试在一个长文章页面上对图片做视差效果:图片在进入视口时轻微缩放,同时背景条随整体滚动变长。CSS 如下:
.parallax-image {
animation: zoom-in linear, bg-progress linear;
animation-timeline: view(), scroll(root);
animation-range: entry 0% entry 100%, 0% 100%;
}
这种声明式的组合让复杂滚动联动变得清晰可控,不再需要手动管理多个事件的先后依赖。
性能方面,因为滚动驱动动画运行在合成器线程,它们不会因主线程阻塞而卡顿。但仍需注意避免在动画属性中使用会触发 layout 的属性(如 `width`、`height`、`margin`),尽量使用 `transform` 和 `opacity`,这样动画可以被完全 GPU 合成。上文中的进度条用了 `width` 属性,属于布局属性,理论上可能触发重排,但实际性能影响通常很小,因为进度条尺寸极简且浏览器对常见场景有优化。如果想要极致性能,可以用 `transform: scaleX()` 配合 `transform-origin: left` 来实现进度条,完全避免布局开销。这里为了可读性展示的是宽度的方案。
五、兼容性与渐进增强策略
截至这篇文章写作时,滚动驱动动画在 Chrome 115+、Edge 115+、Opera 101+ 中已经全面支持,Firefox 在 110+ 版本通过实验性标志开启,Safari 17.2+ 也加入了支持行列。移动端 iOS 17.2+ 同样可用。覆盖率已经比较可观,但离全平台稳定还有距离。
对于不支持的环境,可以采用渐进增强的策略:为元素设置一个不依赖动画的初始状态(可见且正常位置),然后将动画相关属性放在 `@supports` 查询中,确保不支持时不出现问题。
p {
opacity: 1; /* 默认可见,防止在不支持的环境下隐藏 */
transform: none;
}
@supports (animation-timeline: view()) {
p {
opacity: 0;
transform: translateY(20px);
animation: fade-in linear;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
这样,不支持滚动驱动动画的浏览器会直接显示所有内容,没有淡入效果但内容完全可读,支持的环境则获得增强的动画体验。在实际博客项目中,我保留了原本的 Intersection Observer 作为回退,在 `@supports` 内部通过 CSS 自定义属性覆盖,避免了重复代码。
六、滚动驱动动画 vs JavaScript 方案的全面对比
从开发体验看,声明式的 CSS 时间线更加直观:一个规则描述了动画与滚动的绑定关系,另一个规则描述动画本身,两者分离清晰。JavaScript 往往需要混合计算、状态维护和样式应用,逻辑散落在多个函数中。
性能方面,CSS 方案免除了主线程负担,彻底消除了因页面繁忙导致的滚动卡顿。在较长文档中同时驱动数十个段落动画时,浏览器只需要在合成线程上按滚动位置推进 `keyframes`,而 JS 需要逐个元素查验位置并修改样式,差距会随元素数量增加而扩大。
灵活性上,滚动驱动动画目前对“元素在视口中的停留时间”等更复杂的时序控制还比较受限,而 JavaScript 可以轻易实现“元素进入视口后延迟 0.5 秒再开始动画”这种需求。不过 `animation-range` 和偏移量的组合已经能覆盖大部分常见场景。
七、总结:是时候把一些滚动逻辑还给CSS了
重构博客项目的过程,让我再次确认了一件事:但凡能用声明式解决的问题,尽量别用命令式脚本。以前为了进度条写的那几十行代码,现在被三条 CSS 规则替代;段落的入场动画,也从繁琐的观察器配置变成了两行属性声明。代码仓库里的 JavaScript 文件瘦了一圈,心里随之多了一份踏实。
如果你的项目中也有类似的滚动交互,或者正在准备构建一个内容为主、重体验的站点,不妨从进度条或段落淡入这种低风险场景入手,体验一下滚动驱动动画带来的开发效率提升。不需要彻底放弃 JavaScript,但把那些纯粹跟随滚动变化的视觉效果还给 CSS,绝对是一个正确的方向。

