写了三年滚动动画,每次滚动进度都要靠 JS 硬算——今天 CSS 自己会了,scroll-driven animations 把这件事彻底原生化了

滚动动画,你可能还在用这套:监听 scroll 事件,计算滚动比例,requestAnimationFrame 更新 transform。或者是 IntersectionObserver 切 class,JS 和 CSS 配合着跑。

2026 年了,这件事不用再靠 JS 了。

Chrome 115(2023 年 7 月)首发,Safari 26(2025 年 9 月)补完,Firefox 133 全面启用——CSS Scroll-Driven Animations 已经成为 Baseline 2026,三大引擎全部支持,覆盖率约 85%。

两行核心代码

阅读进度条——以前要这样写:

“`javascript
window.addEventListener(“scroll”, () => {
const scrolled = window.scrollY;
const total = document.documentElement.scrollHeight – window.innerHeight;
progressBar.style.transform = `scaleX(${scrolled / total})`;
});
“`

现在一行 CSS:

“`css
.progress-bar {
position: fixed;
top: 0; left: 0;
height: 3px;
background: #0070f3;
transform-origin: 0 50%;
animation: grow linear;
animation-timeline: scroll(root block);
}
@keyframes grow { to { transform: scaleX(1); } }
“`

animation-timeline: scroll(root block) 告诉浏览器:用页面的纵向滚动位置驱动这个动画。滚动多少,动画就走到哪里。

滚动淡入——以前要这样写:

“`javascript
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) entry.target.classList.add(“visible”);
});
}, { threshold: 0.2 });
“`

现在两行 CSS:

“`css
.reveal {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: none; }
}
“`

animation-timeline: view() 把动画绑定到「元素在视口中的可见程度」。animation-range: entry 0% cover 30% 的意思是:从元素进入视口开始播放,到它覆盖视口 30% 时播完。

animation-range 在控制什么

这个属性是 Scroll-Driven Animations 的精髓——它用时间线坐标来描述动画的起止时机:

  • entry 0%:元素刚进入视口那一刻
  • entry 100%:元素完全进入视口那一刻
  • exit 0%:元素开始离开视口那一刻
  • cover 0%:元素任何部分刚接触视口
  • contain 0%:元素被视口完全包含的那一刻

组合起来,你可以精确描述「元素进入视口 20% 时开始动画,到 80% 时结束」:

“`css
animation-range: entry 20% entry 80%;
“`

这就是过去 IntersectionObserver 用 threshold 配置勉强模拟的东西,现在原生支持,还更精确。

为什么比 JS 好

渲染线程之外。JS scroll 监听器和 IntersectionObserver 回调都跑在主线程上。主线程一忙,动画就开始卡帧。Scroll-Driven Animations 由渲染引擎直接处理,动画的每一步都在合成线程完成,主线程感知不到。

没有回调,没有状态同步。JS 版本要维护「当前是否已触发」的状态,还要处理重复触发、清理 observer 等边界情况。CSS 版本没有这个概念——动画绑在元素本身上,不存在状态丢失的问题。

声明式的可读性。看到 animation-timeline: view(),你知道这个动画是靠元素可见性驱动的。阅读 JS 版本需要追踪整个监听逻辑才能还原这个意图。

什么时候还是需要 IntersectionObserver

Scroll-Driven Animations 是纯视觉的。它不能:

  • 懒加载图片(需要网络请求)
  • 触发一次性的事件(动画播完不会自动停止,需要 unobserve 逻辑)
  • 根据可见比例执行不同逻辑(多个 threshold 对应不同行为)

这三个场景里,IntersectionObserver 仍然是你需要的工具。它们不是「动画」,是「由可见性触发的副作用」。

一个常见陷阱

animation 这个简写属性会悄悄把 animation-timeline 重置为默认值 auto。写反了顺序,timeline 就失效了:

“`css
/ 错了——timeline 被 animation 覆盖了 /
.bar { animation-timeline: scroll(root block); animation: grow linear both; }

/ 正确——animation 简写在先,timeline 单独写 /
.bar { animation: grow linear both; animation-timeline: scroll(root block); }
“`

Firefox 还有一个额外要求:需要设置一个非零的 animation-duration 才能让 scroll-driven 动画生效。加上 animation-duration: 1ms 成本几乎为零。

渐进增强

用 @supports 做守卫,没有支持的浏览器看到的是基础状态,不会看到空白:

“`css
.reveal { opacity: 1; transform: none; }

@supports (animation-timeline: view()) {
.reveal {
opacity: 0; transform: translateY(20px);
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal { animation: none; }
}
“`

降级后用户看到的是「已完成状态」——完全可读,不破坏任何东西。

下一步

找出项目里依赖 scroll 监听或 IntersectionObserver 做动画的那段 JS 代码,数一数它有多少行。如果它是纯视觉动画——淡入淡出、位移、缩放——用上面的模式重写,通常不超过 5 行 CSS。

如果它同时在触发副作用(加载数据、打印日志),把那部分保留在 JS 里,动画部分交给 CSS。两件事分开做,各自更清晰。

评论区

0 条评论

登录后可评论。