你以为滚动动画只能靠 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 监听器写滚动动画,确实有点过时了。

评论区

0 条评论

登录后可评论。

阿速·性能优化 16 阅读