配了三年 IntersectionObserver,每次写 reveal 动画都要靠它——今天 Chrome 146 把这件事彻底变成了纯 CSS,5 行顶 11 行
reveal 动画我配了三年,一直是这套:IntersectionObserver 盯着元素,进入视口就调用 animate(),然后 unobserve() 防止重复触发。11 行,跑在主线程上,每次都要等 JS 执行完才能开始做动画这件事。
Chrome 146 把这套逻辑彻底原生化了。
这件事的本质是把「什么时候」从 JS 里拆出来了
滚动动画以前只有一种:animation-timeline: scroll() 或者 view(),动画进度和滚动位置强绑定,你滚它就动,你停它就停。这解决的是「进度跟着滚动走」的问题。
但 reveal 动画的本质不是这个。它是「到了某个点,播一段固定时长的动画」。进度和滚动没关系,时长是动画自己的。
IntersectionObserver 解决的就是这个问题,但它在 JS 里。
Chrome 146 这次把这件事也原生化了,名字叫 scroll-triggered animations,用的是 timeline-trigger + animation-trigger 这两个新属性。
旧写法:11 行主线程 JS
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.animate([
{ opacity: 0, translate: '0 32px' },
{ opacity: 1, translate: '0 0' }
], { duration: 600, easing: 'ease-out', fill: 'both' });
io.unobserve(entry.target);
}
});
});
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
新写法:5 行纯 CSS
.reveal {
animation: fade-up 0.6s ease-out both;
animation-trigger: --t play-forwards;
timeline-trigger-name: --t;
timeline-trigger-source: view();
}
@keyframes fade-up {
from { opacity: 0; translate: 0 32px; }
to { opacity: 1; translate: 0 0; }
}
timeline-trigger-source: view() 告诉浏览器盯着元素和视口的交叉状态,timeline-trigger-name 给它起了个名字叫 –t,animation-trigger: –t play-forwards 说的是「当 –t 触发时,执行 play-forwards 操作」。
触发的阈值默认是 entry 100% exit 0%,意思是元素完全进入视口时触发,离开时解除。如果要精细控制范围,可以写:
timeline-trigger: --t view() contain 15% contain 85% / entry 100% exit 0%;
contain 15% contain 85% 是激活范围,entry 100% exit 0% 是有效范围。这两个范围分离是 scroll-triggered animations 最有意思的设计——激活和生效可以脱钩。
另一个关键区别:scroll-driven 和 scroll-triggered 不是替代关系,是两套工具
| scroll-driven | scroll-triggered | |
|---|---|---|
| 进度控制 | 绑定滚动位置 | 固定时长 |
| 滚动停止时 | 动画暂停 | 继续播放 |
| 典型场景 | 视差、进度条 | 卡片入场、fade-in |
| 引进版本 | Chrome 115 (2023) | Chrome 146 (2026) |
想明白这一点,就知道什么时候用哪个了。
action 关键词决定动画行为
animation-trigger 后面跟的关键词决定触发后的行为:
play-forwards:进入时播放,离开时回退play-once:进入时播放一次,永久保留结束状态,不再重复play:从最后方向继续pause/reset/replay:暂停、重置、重播
最实用的是 play-once + animation-fill-mode: forwards 的组合:动画只在首次入场时触发一次,结束状态永久保留,不会因为用户滚回去再滚回来而重复播放。
为什么这对性能有意义
IntersectionObserver 跑在主线程上,每次交叉状态变化都要触发 JS 回调,然后才能驱动 Web Animations API。scroll-triggered animations 的核心逻辑运行在浏览器内部,和滚动处理并行,不抢主线程。
这在 INP(Interaction to Next Paint)上是真实差异。2025 年 Web Almanac 报告 43% 的站点还没过 INP 200ms 门槛,主线程上跑的东西越少,分越稳。
生产环境怎么用
Chrome 146 首发,其他浏览器还在跟进的路上(Firefox 149、Safari 26.4 已有部分支持)。生产环境要渐进增强:
@supports (animation-trigger: var(--t)) {
.reveal {
animation: fade-up 0.6s ease-out both;
animation-trigger: --t play-forwards;
timeline-trigger-name: --t;
timeline-trigger-source: view();
}
}
不支持的浏览器不会解析这几个属性,动画不触发,内容正常显示——降级体验是安全的。
trigger-scope 防止命名冲突:如果页面上有多个组件各自有 –t 这个名字的 trigger,用 trigger-scope: –t 把作用域限制在当前组件内,否则后面的会覆盖前面的。
下一步
把项目里用 IntersectionObserver 做 reveal 的地方列出来,逐个迁移。先从不支持拖拽、不需要复杂编排的简单入场动画开始,积累手感。等 Firefox 和 Safari 支持度上来,这套写法会变成事实标准。
这件事的本质不是「又有一个新 API 可以用」,而是「滚动动画的时机控制终于从 JS 引擎里独立出来了」。
—
Sources: Chrome Developers (官方博客), CSS-Tricks (深度解析), web.dev New to the web platform March 2026
评论区
登录后可评论。