配了三年 IntersectionObserver,每次写 reveal 动画都要靠它——今天 Chrome 145 把这件事彻底原生化了,5 行 CSS 替换 11 行 JS,性能还更好
我盯了三年 IntersectionObserver,每次写 reveal 动画都要靠它——今天 Chrome 145 把这件事彻底原生化了,5 行 CSS 替换 11 行 JS,性能还更好。
事情是这样的
配滚动触发动画,前几年只有一条路:IntersectionObserver。
这套流程写过一次的人都懂——建 observer、监听元素、entry.isIntersecting 时调用 animate()、然后 unobserve()。功能没问题,但它是跑在主线程的 JS,每次滚动都在和你的业务逻辑抢资源。
2026 年了,Chrome 145 带来了 scroll-triggered animations,把这套逻辑直接做进了浏览器渲染引擎里。
scroll-driven vs scroll-triggered:两个东西,别混了
在说新特性之前,先把这两个概念分清楚。
scroll-driven animation:动画进度和滚动位置连续绑定。滚多少,动画就走到多少。进度条随页面滚、视差效果——这类用 scroll() 或 view() 的 timeline,动画和滚动是同步的。
scroll-triggered animation:时间轴动画,但触发时机由滚动位置决定。元素进入视口 → 动画按自己的时长播放一遍 → 播完停在终点。和 IntersectionObserver 做的事情一样,只是实现方式从 JS 变成了 CSS。
两个东西解决不同问题,别混用。
11 行 JS,5 行 CSS
这是最直接的对比。
原来的写法:
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));
现在的写法:
.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;
}
}
5 行 CSS 替换了 11 行 JS,还少了 unobserve 的清理逻辑。
三个关键属性
timeline-trigger-source: view():告诉浏览器用元素的视口进入状态作为触发源。view() 会跟踪元素在视口中的可见性变化,和 IntersectionObserver 的逻辑完全对应。
timeline-trigger-name: –t:给触发器起个名字。名字空间是全局的,所以建议用 dashed ident(–xxx)的格式。
animation-trigger: –t play-forwards:当 –t 触发器激活时,执行 play-forwards 动作——动画从 0% 播到 100%,然后停在终点。
两个细节决定用对还是用错
第一个:play-forwards 和 play-once 的区别。
play-forwards 每次元素进入触发区都会重新播放。配合 animation-fill-mode: forwards 使用时,如果元素离开视口又重新进入,动画会重新触发——视觉上就是一次”闪”。
如果希望动画只触发一次,之后永久保持:play-once + forwards fill-mode。
.square {
animation: fade-bg-in 300ms forwards;
animation-trigger: --trigger play-once;
timeline-trigger: --trigger view() entry 100% exit 0%;
}
这个组合常用于”一次性入场动画”——卡片第一次滑入之后就固定不动了。
第二个:entry 和 exit 范围。
entry 100% exit 0% 的意思是:元素完全进入视口(底部边缘进入)时触发,元素顶部边缘离开视口时停用/反向。
常见写法:
entry 100% exit 0%:完全进入时触发,完全离开时反向entry 0% exit 100%:元素顶部一进入就触发,完全离开才停用contain 15% contain 85%:在视口中段 15%~85% 区间内保持激活
进阶:跨元素触发和 stagger
一个触发器可以同时控制多个动画。通过 trigger-scope 限制名字的作用域,可以避免全局名字污染:
.card {
trigger-scope: --card-trigger;
timeline-trigger: --card-trigger view() contain / entry 100% exit 0%;
animation: unclip 0.4s ease-in-out both;
animation-trigger: --card-trigger play-forwards play-backwards;
}
如果要实现 stagger 效果,每个卡片的 entry 时间点错开:
.card {
--stagger-interval: calc(100% / sibling-count());
--entry-point: calc(sibling-index() * var(--stagger-interval));
timeline-trigger: --t view() entry var(--entry-point) exit 0%;
}
sibling-index() 和 sibling-count() 会返回元素在兄弟列表中的位置,浏览器自己会算——不需要 JS 手动算 index。
性能账:为什么这件事值得关注
IntersectionObserver 跑在主线程。页面滚动时,浏览器需要遍历所有 observer、计算每个目标元素的可见性、然后触发回调——这些都是 JS 执行。
scroll-triggered animations 的触发逻辑做进了浏览器的布局引擎。浏览器在计算布局时”顺手”判断了触发条件,不需要额外触发 JS 回调。
对于一个拥有 20 个 reveal 动画的落地页:
- IntersectionObserver 方案:每次滚动事件都可能触发 20 次回调
- scroll-triggered 方案:零 JS 回调,动画进度由合成器线程独立更新
这不是微优化。JS 主线程的空闲程度直接影响 INP(Interaction to Next Paint),用 CSS 原生方案把这个负担卸掉,对真实用户体验有可测量的影响。
浏览器支持
Chrome 145+ 已支持。Safari 和 Firefox 还在跟进中。
渐进增强写法:
/* 默认:元素直接可见(降级方案) */
.reveal {
opacity: 1;
transform: none;
}
/* 现代浏览器:使用 CSS 动画 */
@supports (animation-timeline: view()) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}
@supports 检测比 UA 检测更可靠——Safari 哪天支持了,动画自然切换过去,不需要改代码。
下一步
这周挑一个你项目里的 reveal 动画,把 IntersectionObserver 那套逻辑卸掉,换成 animation-trigger + timeline-trigger-source: view()。11 行 JS 变 5 行 CSS,主线程少一堆回调。
不要一次全换。先换一两个 section,用 Chrome DevTools 的 Performance 面板对比一下 JS 执行时间,感受一下差异。
评论区
登录后可评论。