前端技术
#CSS#滚动驱动动画#animation-range#IntersectionObserver

CSS 滚动驱动动画的 range:如何精确控制动画的起止进度

本文聚焦 CSS 滚动驱动动画中 animation-range 的机制,解释如何将动画时间线映射到滚动容器的特定区间,并与默认的全区间映射对比。以长页面中多个元素依次进入视口触发动画为例,结合 IntersectionObserver 方案分析 range 的触发时机、性能影响、适用边界与浏览器支持。

一个需要精确控制触发时机的场景

假设你正在开发一个商品长页面,页面由多个区块组成,每个区块包含一张商品图和一段说明文字。设计稿要求:当区块滚动进入视口时,图片从轻微放大且半透明的状态过渡到正常显示,文字则从下方滑入。你希望每个区块的动画只在它进入视口的那一段滚动距离内播放,而不是从页面顶部滚动到底部的整个过程中缓慢变化。

传统做法是用 IntersectionObserver 监听每个区块的可见性,当区块进入视口时给元素添加一个类名,触发一次基于时间的 CSS 过渡或动画。这个方案能工作,但存在几个问题:监听器数量随区块数量增长,每个监听器回调都在主线程执行,滚动过程中频繁触发回调可能造成卡顿;动画一旦触发就按固定时长播放,无法与用户的滚动速度同步,也无法在用户快速滚动时反向播放。

CSS 滚动驱动动画提供了一种声明式的替代方案。它把动画的进度直接绑定到滚动位置,而不是绑定到时钟时间。其中 animation-range 属性允许你精确指定动画在时间线上的哪一段区间内生效,这正是控制“元素进入视口时才开始动画”的关键机制。

滚动驱动动画的时间线类型与默认映射

CSS 滚动驱动动画规范定义了两种时间线:滚动进度时间线(Scroll Progress Timeline)和视图进度时间线(View Progress Timeline)。

滚动进度时间线与滚动容器沿某一轴的滚动位置关联,滚动容器的起始滚动位置对应 0% 进度,结束位置对应 100% 进度。它适合做阅读进度条、视差背景这类与整个滚动距离相关的效果。

视图进度时间线与某个元素(称为“主题”)在滚动容器中的相对位置关联。当主题开始进入滚动容器的可视区域(scrollport)时,进度从 0% 开始;当主题完全离开可视区域时,进度达到 100%。这种时间线天然适合“元素进入视口时播放动画”的需求,因为它跟踪的正是主题与视口的相交过程。

在 CSS 中,你可以用 view() 函数创建匿名的视图进度时间线,也可以通过在滚动容器上声明 view-timeline-name 创建命名时间线。把时间线赋给元素的 animation-timeline 属性后,动画的进度就由滚动位置驱动。

默认情况下,动画会附加到视图进度时间线的整个范围,也就是从主题开始进入视口到完全离开视口。这个范围在规范中被称为 cover 范围。如果你不设置 animation-range,动画会在这个完整区间内从 0% 播放到 100%。

问题在于,cover 范围往往不是你想要的效果。以商品区块为例,如果动画从区块刚露出一像素就开始,到区块完全离开视口才结束,那么当区块还在视口底部时动画就已经播放完毕,用户可能根本看不到完整的过渡;而当区块快速滚过时,动画又会被压缩得很短。更合理的做法是让动画只在“区块进入视口”的那一段区间内播放,也就是 entry 范围。

animation-range 如何映射时间线区间

animation-range 属性用于指定动画附加到时间线的哪一段区间。它接受两个值:起始点和结束点,每个点由范围名称和偏移量组成。范围名称对应时间线上预定义的命名区间,偏移量可以是百分比或长度值。

对于视图进度时间线,规范定义了以下命名范围:

  • cover:主题从开始进入视口到完全离开视口的完整范围。
  • entry:主题从开始进入视口到完全进入视口的范围,即主题的前边缘从视口后边缘进入,到主题完全覆盖视口。
  • exit:主题从开始离开视口到完全离开视口的范围。
  • entry-crossing:主题跨越视口后边缘的范围,即主题的前边缘从视口后边缘进入,到主题的后边缘也进入视口。
  • exit-crossing:主题跨越视口前边缘的范围。
  • contain:主题完全包含在视口内的范围,即主题完全可见且未超出视口边界。

这些范围的具体几何含义可以借助官方提供的可视化工具理解,但核心思想是:每个范围都对应主题与视口相交过程中的一个特定阶段。

animation-range 的语法是 animation-range: <range-start> <range-end>,其中每个值可以是 entry 0%exit 100% 这样的组合。例如,要让动画在主题进入视口的整个过程中播放,可以写成 animation-range: entry 0% entry 100%

为了更直观地理解,可以想象一个商品区块从视口底部向上滚动。当区块的顶部边缘刚碰到视口底部时,这是 entry 0% 的位置;当区块完全进入视口,即底部边缘也进入视口时,这是 entry 100%。如果把动画范围设置为 entry 0% entry 100%,那么动画会从区块刚开始露头时启动,到区块完全进入视口时结束。

下面的流程图展示了商品区块从进入视口到离开视口的过程中,不同范围对应的滚动位置:

flowchart TD
    A[滚动容器顶部] --> B[区块完全在视口下方]
    B --> C[entry 0%: 区块顶部进入视口底部]
    C --> D[entry 100%: 区块完全进入视口]
    D --> E[cover 100%: 区块开始离开视口]
    E --> F[exit 100%: 区块完全离开视口]
    F --> G[滚动容器底部]

在这个流程中,entry 范围只覆盖从 C 到 D 的区间,而 cover 范围覆盖从 C 到 F 的整个区间。通过设置 animation-range: entry 0% entry 100%,动画被限制在 C 到 D 之间播放,区块完全进入视口后动画就结束,不会继续随着滚动而变化。

用 range 控制多个元素依次触发

回到商品长页面的场景。每个商品区块都使用视图进度时间线,并设置 animation-rangeentry 范围,这样每个区块的动画只在自己的进入阶段播放。

关键代码示意如下:

.product {
  animation: fade-in linear;
  animation-timeline: view(block);
  animation-range: entry 0% entry 100%;
}

@keyframes fade-in {
  from { opacity: 0; transform: scale(0.95); }
  to { opacity: 1; transform: scale(1); }
}

这里 view(block) 创建了一个匿名的视图进度时间线,跟踪元素在最近的祖先滚动容器中的可见性,轴为块方向(垂直)。animation-range 将动画限制在 entry 范围内。

由于每个区块都有自己的视图进度时间线,它们互不干扰。当区块 A 还在视口下方时,它的动画进度为 0%;当 A 开始进入视口,动画开始播放;当 A 完全进入视口,动画结束。此时区块 B 可能才刚刚开始进入视口,它的动画才开始播放。这样,多个元素的动画依次触发,且每个动画的进度与滚动位置严格同步。

如果用户快速向下滚动,区块 A 可能还没完全进入视口就滚过了,那么它的动画只播放了一部分,甚至可能反向播放(如果用户向上滚动)。这正是滚动驱动动画与时间驱动动画的重要区别:动画可以被滚动位置“擦除”,而不是一旦触发就不可逆地播放。

这种声明式写法避免了 JavaScript 监听器。浏览器可以在合成器线程上处理滚动驱动的动画,无需在主线程上执行回调,从而减少卡顿风险。

与 IntersectionObserver 方案的对比

IntersectionObserver 是传统的实现“进入视口触发动画”的方案。它异步观察元素与视口的交叉状态,当交叉比例超过阈值时触发回调,在回调中切换类名或直接修改样式,从而启动 CSS 过渡或动画。

两种方案在触发机制上有本质区别:IntersectionObserver 是“触发后交给时间驱动”,而 animation-range 是“持续由滚动驱动”。

下面的表格从多个维度对比了两种方案:

维度IntersectionObserver 方案animation-range 方案
动画驱动源触发后由时钟时间驱动由滚动位置持续驱动
与滚动同步不同步,触发后按固定时长播放完全同步,可随滚动前进或后退
主线程占用需要 JavaScript 回调,主线程执行纯 CSS 声明,可运行在合成器线程
多个元素管理需要为每个元素创建 observer,或使用共享 observer 手动管理每个元素独立声明,无需额外逻辑
触发时机控制通过 threshold 和 rootMargin 近似控制通过 range 精确指定进入、离开等阶段
反向滚动表现动画不会反向,可能已播放完毕动画会反向擦除,回到之前的进度
浏览器支持广泛支持Chrome 115+、Edge 115+、Firefox 111+(部分支持)
渐进增强可直接使用需要 @supports 检测并提供降级

从表格可以看出,animation-range 在滚动同步性和性能方面有优势,但 IntersectionObserver 的浏览器支持更广泛。在需要兼容旧浏览器的场景中,你可能仍然需要 IntersectionObserver 作为降级方案。

性能影响与合成器线程

滚动驱动动画的一个关键优势是可能运行在合成器线程上。传统上,滚动事件在浏览器的主线程上触发,如果主线程忙于执行 JavaScript,滚动动画就会出现卡顿。而 CSS 滚动驱动动画通过声明式 API 定义,浏览器可以在合成器线程上直接计算动画进度,无需等待主线程。

但这并不意味着所有滚动驱动动画都一定在合成器线程上运行。动画是否能在合成器线程上运行,取决于动画的属性是否可合成。例如,transformopacity 是常见的可合成属性,而 widthheightclip-path 等属性则可能触发主线程布局或绘制。

在商品区块的淡入动画中,我们使用了 opacitytransform,这两个属性都是可合成的,因此动画有望在合成器线程上运行。如果动画中包含 clip-pathbackground-color 等属性,则可能需要在主线程上执行,性能收益会打折扣。

此外,animation-range 本身不会增加额外的性能开销,它只是定义了动画的起止点。真正影响性能的是动画属性的选择、滚动容器的复杂度以及动画元素的数量。

与 IntersectionObserver 相比,使用 animation-range 可以避免大量 JavaScript 回调。假设页面有 20 个商品区块,使用 IntersectionObserver 就需要管理 20 个观察目标,每个目标进入视口时都会触发回调。虽然 IntersectionObserver 的回调是异步的,但回调本身仍会占用主线程时间,尤其是在快速滚动时,可能同时触发多个回调。而 CSS 声明式动画则没有这些回调,浏览器可以更高效地处理。

常见失败模式与边界条件

animation-range 虽然强大,但在实际使用中需要注意一些边界条件,否则可能出现动画表现不符合预期的情况。

第一个常见问题是动画元素在滚动容器中不可见时,动画进度为 0% 或 100%。如果元素在视口下方,它的视图进度时间线进度为 0%,动画处于起始状态;如果元素在视口上方,进度为 100%,动画处于结束状态。这可能导致元素在进入视口之前就处于动画的起始状态(例如透明度为 0),如果元素本身需要可见,就会造成闪烁。

解决方法是使用 animation-fill-mode 或调整动画范围。例如,如果希望元素在进入视口前保持可见,可以设置 animation-fill-mode: backwards,但这只影响动画开始前的状态。更可靠的做法是确保动画的起始状态与元素的默认样式一致,或者使用 animation-range 将动画延迟到元素真正可见时才播放。

第二个问题是当滚动容器没有足够的滚动空间时,视图进度时间线可能无法完成完整的 entry 范围。例如,如果滚动容器的高度很小,而元素很高,元素可能永远无法完全进入视口,导致 entry 100% 无法达到,动画永远无法播放完毕。这时需要调整 animation-range 的偏移量,或者使用 contain 范围。

第三个问题是嵌套滚动容器。view() 函数默认跟踪最近的祖先滚动容器,如果元素被多个可滚动容器包裹,你需要确保指定了正确的滚动容器。命名视图时间线可以解决这个问题,但需要手动声明 view-timeline-nameview-timeline-axis

第四个问题是浏览器兼容性。animation-range 是较新的 CSS 特性,在撰写本文时,Chrome 和 Edge 115+ 支持,Firefox 111+ 支持(但可能不完整),Safari 尚未支持。因此,在生产环境中使用前,需要检测浏览器支持情况,并提供降级方案。

浏览器支持与渐进增强

截至 2026 年,CSS 滚动驱动动画的浏览器支持情况如下:Chrome 和 Edge 从 115 版本开始支持,Firefox 从 111 版本开始支持,Safari 从 26 版本开始支持(根据 MDN 文档)。但不同浏览器对规范细节的支持程度可能不同,例如某些命名范围或偏移量语法可能尚未完全实现。

在支持不完整的情况下,可以使用 @supports 规则进行特性检测,并提供基于 IntersectionObserver 的降级方案。例如:

@supports (animation-timeline: view()) {
  .product {
    animation: fade-in linear;
    animation-timeline: view(block);
    animation-range: entry 0% entry 100%;
  }
}

@supports not (animation-timeline: view()) {
  .product {
    /* 降级为 IntersectionObserver 触发的动画 */
  }
}

降级方案需要 JavaScript 配合,但可以确保在不支持滚动驱动动画的浏览器中,元素仍然能以某种方式进入视口时触发动画。

需要注意的是,animation-range 的语法仍在规范演进中,某些细节可能在未来版本中调整。因此,在关键生产环境中,建议持续关注规范更新和浏览器实现状态。

总结与适用边界

animation-range 为滚动驱动动画提供了精确的区间控制,使得“元素进入视口时播放动画”这类需求可以用纯 CSS 声明式实现,并获得与滚动同步的体验。它的核心价值在于将动画的进度与滚动位置绑定,而不是与时间绑定,从而避免了传统方案中动画与滚动脱节的问题。

然而,它并非万能。如果你的动画需要在触发后独立于滚动继续播放(例如播放一次后保持状态),或者需要复杂的交互逻辑,IntersectionObserver 可能更合适。此外,在浏览器支持不完整的场景中,你仍然需要降级方案。

在决定使用哪种方案时,可以基于以下判断:如果动画效果是纯视觉的、与滚动位置强相关,且目标浏览器支持滚动驱动动画,那么 animation-range 是更好的选择;如果动画需要与用户的其他操作(如点击)联动,或者需要复杂的时序控制,那么基于时间的动画可能更灵活。

最后,无论使用哪种方案,都应该在真实设备上测试滚动性能,观察是否有主线程长任务或掉帧现象。滚动驱动动画虽然设计为高性能,但不当的属性选择或过多的动画元素仍可能导致性能问题。

资料来源

  1. CSS Scroll-Driven Animations Level 1 - W3C Working Draft
  2. Scroll-driven animations - MDN Web Docs
  3. 使用滚动驱动的动画,在滚动时为元素添加动画效果  |  CSS and UI  |  Chrome for Developers
  4. 开始在CSS 中使用滚动驱动的动画 - Codelabs