无需 JavaScript,在滚动时触发 CSS 动画
无需JavaScript即可创建滚动触发的CSS动画。了解语法、触发行为、滚动边界设置,以及使用IntersectionObserver的备用方案。
滚动触发动画(scroll-triggered animations)让滚动来决定一个普通 CSS 动画何时开始。一旦元素到达设定的滚动位置,动画就会按照自身的时钟独立播放。你可以通过 timeline-trigger 和 animation-trigger 属性来声明它们,完全不需要 IntersectionObserver。
大多数”滚动时淡入”的代码几乎千篇一律:一个 observer、一个阈值、一次 class 切换,外加一个总有人忘记写的 unobserve() 调用。如今,这些工作都可以交给 CSS 完成。
本文将介绍相关语法、一个完整的元素显现(reveal)示例、三种触发行为、如何选择滚动边界,以及在目前只有 Chromium 支持的情况下如何安全地上线这一特性。
核心要点
- 一个滚动触发动画需要两条声明。
timeline-trigger为触发器命名,并设置激活它的滚动范围;animation-trigger告诉动画应跟随哪个触发器,以及触发时执行什么操作。 - 范围
entry 100% exit 0%仅在元素完全位于滚动视口(scrollport)内时激活触发器。Chrome 文档将其视为与contain关键字等价。 play-forwards play-backwards会在进入时播放动画,并在离开时反向播放。来自 Animation Triggers 草案的play-once则只运行一次。- 触发式动画保持自身的时长和缓动,因此滚动得更快并不会让动画播放得更快。
- Chrome 的支持数据显示,滚动触发动画目前仅在 Chromium 系浏览器中可用,因此应将隐藏内容的样式放在
@supports内,并保留 IntersectionObserver 作为降级方案。
滚动驱动动画与滚动触发动画有何区别?
滚动驱动动画(scroll-driven animations)将动画进度与滚动位置绑定;滚动触发动画则仅用滚动来决定一个普通动画何时开始。我们关于如何仅用 CSS 创建滚动动画的指南介绍的是第一种模型:结合 scroll() 和 view() 使用 animation-timeline。在那种模型中,时间线取代了时钟,因此向回滚动时动画会反向播放。而本文介绍的是 timeline-trigger 和 animation-trigger,它们会在元素到达选定的滚动范围后,启动一个拥有自身时长的常规动画。
如果你需要阅读进度条或视差效果,请使用 CSS 滚动进度动画。如果你希望卡片在可见后用 400ms 淡入,就使用触发器。在 Google I/O 2026 Web UI 汇总中,Chrome 将两者列为独立特性,并将触发器描述为用 CSS 替代 IntersectionObserver 的方式。简单来说,触发器就是”当元素可见时添加一个 class”的 CSS 版本。
滚动触发淡入示例
滚动触发的淡入效果需要三部分:一条 @keyframes 规则、一个使用 forwards 填充模式的 animation,以及一个将动画与视图时间线关联起来的触发器。Chrome 关于滚动触发动画的公告定义了相关语法,其要点在于:触发器和动画可以位于不同的元素上,并且触发器以滚动进度时间线或视图进度时间线作为来源。
<section class="reveal">
<div class="reveal-content">
<h2>Shipping notes</h2>
<p>This block fades and slides in once it is fully on screen.</p>
</div>
</section>
@keyframes reveal {
to {
opacity: 1;
translate: 0 0;
}
}
@supports (timeline-trigger-name: --t) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
timeline-trigger: --reveal view() entry 100% exit 0%;
trigger-scope: --reveal;
}
.reveal-content {
opacity: 0;
translate: 0 2rem;
animation: reveal 400ms ease-out forwards;
animation-trigger: --reveal play-forwards;
}
}
}
工作原理:
- 触发器位于 section 上,动效位于其子元素上。 发生滑动的元素并不是被检测可见性的元素,因此
translate偏移不会移动触发边界。 entry 100% exit 0%会在 section 完全进入滚动视口后激活触发器。play-forwards会在每次触发器激活时启动动画。forwards填充模式会在动画结束后保持最后一帧。- 隐藏的初始状态写在特性查询内部。 不支持该特性的浏览器永远不会应用
opacity: 0,因此内容始终可见。 trigger-scope让每个 section 的--reveal名称保持局部作用域。默认情况下,页面上的任何元素都能看到某个触发器名称,这与anchor-name的行为相同,详见 Chrome 146 发布说明。
只使用 play-forwards 时,离开范围不会产生任何效果:滚动离开后,元素会保持其结束状态。当元素再次完全可见时,触发器会再次激活,动画随之重播。下一节将介绍如何改变这一行为。
选择触发操作
animation-trigger 接受一个触发器名称,后跟最多两个操作。第一个操作在触发器激活时执行,第二个在触发器失活时执行。你选择的操作决定了元素再次进入和离开时会发生什么。
| 期望行为 | animation-trigger 取值 | 说明 |
|---|---|---|
| 每次重新进入时重播 | --reveal play-forwards | 可能会明显跳回初始状态 |
| 离开时反向播放 | --reveal play-forwards play-backwards | 反向播放结束后,元素显示其基础样式 |
| 只运行一次,然后保持 | --reveal play-once | 草案关键字,可能会变化 |
animation-trigger: --reveal play-forwards play-backwards; /* reverses on exit */
animation-trigger: --reveal play-once; /* runs once; Animation Triggers draft keyword */
play-forwards 结束后,forwards 填充模式会保持 to 关键帧;但 play-backwards 结束后,它不会保持任何状态。此时动画不再生效,元素回退到其基础样式。在本例中,由于关键帧只定义了 to,基础样式就是隐藏的初始状态(opacity: 0; translate: 0 2rem),因此元素最终仍处于隐藏状态。如果你的关键帧设置了与基础样式不同的 from 状态,请使用 both,以便在反向播放后保持该状态。play-once 来自 CSS Animation Triggers Level 1 编辑草案,而非 Chrome 的公告,因此在依赖它之前,请先在目标 Chrome 版本中验证。它是最接近”首次相交后调用 unobserve()”的方案。
激活范围与活动范围
在滚动触发器中,激活范围(activation range)决定触发器何时触发。可选的活动范围(active range)写在斜杠之后,决定触发器保持激活状态的时长,并且 Chrome 要求它必须包含激活范围。根据 Chrome 的说明,entry 100% exit 0% 等同于 contain,entry 0% exit 100% 等同于 cover,因此以下两行是等价的:
timeline-trigger: --reveal view() entry 100% exit 0% / entry 0% exit 100%;
timeline-trigger: --reveal view() contain / cover;
采用这一设置后,触发器会在元素完全可见时开启,并一直保持开启,直到元素完全不可见为止。这一点对 play-backwards 很重要,因为反向动画会等到元素移出屏幕后才执行。
选择合理的滚动边界
合适的滚动触发范围取决于元素的尺寸,以及视口中是否有固定 UI 遮挡。
| 场景 | 范围 | 原因 |
|---|---|---|
| 小卡片或标题 | entry 100% exit 0%(contain) | 在整个元素可见时触发 |
| 较高的 section | entry 25% exit 0% | 对于较高的元素,contain 可能触发得太晚 |
| 位于 4rem 高的粘性头部之下 | view(block 4rem 0) | 避免头部遮挡元素 |
/* Tall element: fire when a quarter has entered */
timeline-trigger: --reveal view() entry 25% exit 0%;
/* 4rem sticky header: start (top) inset first */
timeline-trigger: --reveal view(block 4rem 0) entry 100% exit 0%;
在 view() 中,起始内边距(start inset)写在结束内边距(end inset)之前。在垂直的块轴上,起始端是顶部,因此顶部头部的高度要写在前面。
浏览器支持与降级方案
Chrome 的 Google I/O 2026 Web UI 汇总显示,Chrome 和 Edge 146 支持滚动触发动画,而 Firefox 和 Safari 尚不支持。Chrome 在 2025 年 12 月的公告中称该特性将在 Chrome 145 中发布,但实际是在 Chrome 146 中上线(稳定版于 2026 年 3 月 10 日发布)。Chrome 146 beta 文章显示 trigger-scope 也在同一版本中发布。请将整个特性视为渐进增强(progressive enhancement)。
示例中的 @supports 包裹层正是为此而设。不支持的浏览器会跳过整个代码块,以静态方式渲染内容。如果你希望在其他浏览器中实现相同的动效,可以将现有的 IntersectionObserver class 切换逻辑保留在特性查询之外,并仅在 CSS.supports() 返回 false 时运行。我们关于用原生 Web API 替代动画库的文章介绍了如何在不依赖 AOS 之类库的情况下构建这一降级方案。
prefers-reduced-motion: no-preference 查询意味着希望减少动效的用户会立即看到内容。如果要让多个元素依次错开(stagger)播放,可以让它们的 animation-trigger 指向同一个共享触发器,并设置不同的延迟。
总结
滚动触发动画覆盖了”滚动时淡入”这一模式:由 CSS 决定普通动画何时开始,由浏览器负责观察。从上面的示例入手,选择符合你设计需求的操作(重播、反向播放或只运行一次),针对较高的元素或粘性头部调整范围,并在支持该特性的浏览器中移除 observer 代码,同时为其余浏览器保留它。
常见问题
滚动触发动画与 scroll-state 容器查询有什么区别?
scroll-state 容器查询会在容器处于浏览器管理的某种滚动状态时应用样式。滚动触发动画则是在元素越过视图时间线或滚动时间线上的某个范围时启动关键帧动画。Chrome 133 发布了 stuck、snapped 和 scrollable 状态,Chrome 144 新增了 scrolled 状态,用于跟踪最近一次的滚动方向。对于吸顶后需要改变样式的粘性头部,请使用 scroll-state 查询;对于显现效果,请使用触发器。
滚动触发动画比基于 IntersectionObserver 的显现效果更快吗?
Chrome 的发布说明指出,由于交互是用 CSS 描述的,浏览器可以将这部分工作交给 worker 线程处理。基于 IntersectionObserver 的显现效果会在主线程上运行回调和 class 切换,因此繁重的脚本工作可能会使其延迟。Chrome 将这种卸载描述为设计上的优势,而非实测结果,因此请在切换前后对你自己的页面进行性能分析,而不要想当然地认为会有提升。
滚动触发器能控制通过 Web Animations API 创建的动画吗?
可以。Chrome 146 发布说明指出,该特性附带 JavaScript 接口,因此触发器不仅能驱动 CSS 动画,也能驱动通过 Web Animations API 创建的动画。相关接口名称定义在 CSS Animation Triggers Level 1 编辑草案中,仍可能发生变化,因此在依赖它们之前,请先查阅草案并在目标 Chrome 版本中验证。
为什么添加滚动触发器后元素一直处于隐藏状态?
特性查询一旦匹配,隐藏的初始状态就会立即生效,因此元素会保持 opacity 0,直到其触发器被激活。常见原因包括:timeline-trigger 名称与 animation-trigger 名称拼写不一致、动画元素位于 trigger-scope 所设定的子树之外,或者元素永远无法到达所设定的范围。请先检查名称,再确认动画元素位于作用域元素内部。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k