你以为滚动动画只能靠 JS 撑着跑?今天 CSS 把这件事从主线程彻底解放了
滚动的时候页面一卡一卡地跳,你的第一反应是什么?大多数人去查 JS 性能,或者换一套更轻量的动画库。但真正的问题从来没在动画代码本身——而在于动画跑在哪条线程上。
传统 JS 方案的致命缺陷:主线程阻塞
写一个阅读进度条,常规思路是在 window 上绑 scroll 事件监听器:
window.addEventListener("scroll", () => {
const scrolled = window.scrollY;
const total = document.documentElement.scrollHeight - window.innerHeight;
progressBar.style.transform = `scaleX(${scrolled / total})`;
});
这段代码写在主线程上。每次用户滚动,浏览器要在同一帧内同时完成:计算滚动位置 → 执行监听器回调 → 修改 DOM 样式 → 重新布局 → 绘制。如果页面上还有别的 JS 在跑,进度条就会跳帧,肉眼可见地卡。
IntersectionObserver 改善了这个问题——它不在 scroll 事件上跑,而是浏览器主动通知元素进入了视口。但 Callback 还是在主线程执行的,浏览器仍然无法完全预知你在回调里要做什么,所以优化空间有限。
2019 年的 Android 手机实测数据很能说明问题:20 个带 scroll 监听器的动画元素,帧率掉到 30fps。用户的直观感受就是”滚动不跟手”。
CSS Scroll-Driven Animations 的解决思路:跑在合成器线程
Chrome 115 引入的 CSS Scroll-Driven Animations,从根本上改变了这件事。它把动画进度直接绑定到滚动位置,浏览器在合成器线程上计算动画插值,不需要主线程介入。
还是那个阅读进度条,用 CSS 重写:
@keyframes progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: oklch(0.7 0.2 220);
transform-origin: left;
animation: progress linear;
animation-timeline: scroll();
}
animation-timeline: scroll() 告诉浏览器:用最近祖先滚动容器的滚动位置作为这个动画的时间线。滚动 0% 时动画在 from 帧,滚动 100% 时在 to 帧,中间完全由合成器线程驱动,主线程对此一无所知。同一台 2019 Android 手机,50 个 CSS scroll-driven 动画元素,帧率稳定在 60fps。
这是两倍以上的元素量,帧率反而更高。
两种时间线:scroll() 和 view()
scroll() 绑定滚动容器的绝对位置,适合全局进度指示器:
animation-timeline: scroll(root); /* 文档级滚动 */
animation-timeline: scroll(nearest); /* 最近祖先滚动容器 */
animation-timeline: scroll(self); /* 元素自身 */
view() 绑定元素在视口中的相对可见性,适合入场动画:
@keyframes fade-up {
from { opacity: 0; transform: translateY(1.5rem); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
animation-range: entry 0% entry 30% 的意思是:动画在元素进入视口的前 30% 区间内播放,之后停留在最终状态。这正好是 IntersectionObserver 擅长的场景,但零回调、零主线程。
animation-range 的精细控制
这是 scroll-driven animations 最容易被低估的能力。通过 entry/exit/cover/contain 四个关键字加百分比,你可以精确控制动画发生在哪个区间:
/* 元素完全离开视口时才播放 */
animation-range: exit 0% exit 100%;
/* 元素完整通过视口时播放 */
animation-range: cover 0% cover 100%;
/* 仅当元素完全在视口内时播放 */
animation-range: contain 0% contain 100%;
组合 named timeline 还能实现跨容器的协调动画:
.container {
timeline-scope: --card-timeline;
}
.card {
view-timeline-name: --card-timeline;
}
.card .image {
animation: zoom-in linear both;
animation-timeline: --card-timeline;
animation-range: entry 0% cover 50%;
}
父容器声明 timeline-scope,子元素声明 view-timeline-name,另一个子元素通过名字引用——这三个元素的动画可以通过同一滚动进度协调。
三步上手
第一步:确认浏览器支持。Chrome 115+、Edge 115+、Firefox 122+ 已全面支持,Safari 17.4+ 部分支持(view-timeline 需 polyfill)。用 @supports 做渐进增强:
.reveal {
opacity: 1; /* 不支持的浏览器直接显示 */
}
@supports (animation-timeline: view()) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
第二步:选择 timeline 类型。全局进度用 scroll(root),元素入场用 view(),需要跨元素协调用 named timeline。
第三步:只动画 transform 和 opacity。这两个属性由合成器线程处理,不会触发布局重计算,是 scroll-driven animations 的最佳拍档。
和 GSAP ScrollTrigger 的关系
不是说 ScrollTrigger 要被淘汰了。CSS scroll-driven animations 能覆盖的场景:阅读进度条、sticky 头部动画、元素入场动画、parallax 背景——这些用 CSS 写更干净,性能也更好。
但 GSAP ScrollTrigger 的 pin(固定不动)和 scrub(带阻尼的滚动跟随)能力,CSS 还没有。任何需要”元素在滚动过程中保持固定,同时内部动画平滑跟随”的场景,ScrollTrigger 仍然是正确答案。实际项目中经常两者混用:CSS 处理全页面散落的入场动画,ScrollTrigger 处理首屏复杂编排区。
无障碍别忘
大约 35% 的成年人对屏幕运动敏感(vestibular disorder)。在 CSS 里用一行媒体查询兜底:
@media (prefers-reduced-motion: reduce) {
.reveal, .progress-bar, .parallax {
animation: none;
}
}
不取消内容,只取消动画过程。用户仍然看到最终状态,只是没有过渡。
回到开头的问题:滚动卡的时候,真正的问题不是你的 JS 写得不够好,而是动画跑错了线程。CSS Scroll-Driven Animations 把这件事从工程问题变成了平台能力——声明几行 CSS,浏览器来处理最优路径。2026 年了,还在用 scroll 监听器写滚动动画,确实有点过时了。
评论区
登录后可评论。