写了三年滚动动画,每次滚动进度都要靠 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%。
两行核心代码
阅读进度条——以前要这样写:
window.addEventListener(scroll, () => {
const scrolled = window.scrollY;
const total = document.documentElement.scrollHeight - window.innerHeight;
progressBar.style.transform = `scaleX(${scrolled / total})`;
});
现在一行 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) 告诉浏览器:用页面的纵向滚动位置驱动这个动画。滚动多少,动画就走到哪里。
滚动淡入——以前要这样写:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) entry.target.classList.add(visible);
});
}, { threshold: 0.2 });
现在两行 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% 时结束」:
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 就失效了:
/* ❌ 错了——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 做守卫,没有支持的浏览器看到的是基础状态,不会看到空白:
.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。两件事分开做,各自更清晰。
评论区
登录后可评论。