前端技术
#CSS#scroll-driven-animations#timeline-scope#animation-timeline#滚动动画

CSS 滚动驱动动画的 timeline-scope:为什么命名时间线需要作用域提升

本文讨论 CSS scroll-driven animations 中 timeline-scope 属性的作用:它如何将子元素的命名滚动时间线提升到祖先作用域,使非父子关系的元素也能共享同一时间线。以页面级滚动进度条驱动多个区域动画的场景为例,说明命名时间线的查找机制、作用域限制、timeline-scope 的声明方式及其对动画同步的影响,对比传统 JS 滚动监听方案的性能与复杂度,并给出适用边界与浏览器支持。

一个常见的需求:页面级滚动驱动多个区域动画

假设你正在开发一个长表单页面,页面本身可以滚动。你希望当用户向下滚动时,页面顶部出现一条进度条,同时页面中不同位置的几个卡片依次产生淡入或位移效果。这些卡片并不在同一个容器内,它们分散在页面的不同区块中,而滚动行为发生在页面根元素上。

直觉上,你可能会用 JavaScript 监听 scroll 事件,计算 scrollTop 与总滚动高度的比值,然后通过修改样式或直接操作 transformopacity 来驱动进度条和卡片动画。这种做法在简单场景下可行,但存在两个问题:一是 scroll 事件触发频率很高,如果直接在监听器里做布局读取或样式写入,很容易造成强制同步布局,导致滚动卡顿;二是需要手动管理多个元素的动画状态,代码复杂度随元素数量增加而上升。

CSS scroll-driven animations 提供了一种声明式方案:把动画进度绑定到滚动容器的滚动偏移上,而不是绑定到时钟时间。但这里有一个关键限制——命名时间线的作用域。默认情况下,一个命名滚动时间线只能被它的直接后代元素使用。如果动画元素与滚动容器不是父子关系,比如滚动容器是页面根元素,而动画元素是它内部的某个深层卡片,那么直接引用时间线名称是无效的。这正是 timeline-scope 属性要解决的问题。

命名时间线:从滚动容器到动画元素的桥梁

在 CSS scroll-driven animations 中,有两种时间线类型:滚动进度时间线(Scroll Progress Timeline)和视图进度时间线(View Progress Timeline)。滚动进度时间线直接关联一个滚动容器的滚动偏移,而视图进度时间线关联一个元素通过滚动视口的进度。本文主要讨论前者。

要让一个元素根据滚动容器的滚动位置进行动画,需要两步:

  1. 在滚动容器上定义一个命名时间线,使用 scroll-timeline-name 属性(或 scroll-timeline 简写)。
  2. 在动画元素上通过 animation-timeline 引用这个名称。

例如,一个可滚动的 <div> 容器,内部有一个要旋转的方块:

.scroller {
  overflow-y: scroll;
  scroll-timeline-name: --square-timeline;
}

.square {
  animation: rotate 1ms linear;
  animation-timeline: --square-timeline;
}

这里 .square.scroller 的直接后代,因此可以引用 --square-timelineanimation-duration 设置为 1ms 是一个常见技巧,因为滚动驱动动画的进度不由时间决定,但某些浏览器要求设置一个非零的持续时间才能应用动画。

关键点在于,scroll-timeline-name 定义在滚动容器上,而 animation-timeline 只能被该容器的后代元素使用。规范中明确说明,命名时间线的默认作用域是“定义它的元素及其后代”。换句话说,一个时间线名称只能被定义它的元素的后代元素引用,而不能被祖先或兄弟元素引用。

作用域限制:为什么兄弟元素无法直接共享时间线

回到页面级滚动进度条的案例。页面根元素 <html><body> 是滚动容器,进度条和卡片都是它的后代,看起来应该能直接引用。但实际中,滚动容器往往不是根元素,而是一个中间的 <div>,而动画元素可能位于这个 <div> 的兄弟分支中。

考虑一个典型布局:

<div class="content">
  <div class="box animation"></div>
</div>
<div class="scroller">
  <div class="long-element"></div>
</div>

这里 .scroller 是可滚动容器,.box 是它的兄弟元素的子元素。如果我们在 .scroller 上定义 scroll-timeline-name: --my-scroller,然后在 .box 上设置 animation-timeline: --my-scroller,浏览器会找不到这个时间线,因为 .box 不是 .scroller 的后代。

为什么规范要这样限制?原因在于,命名时间线的查找机制类似于 CSS 变量或自定义属性,它沿着 DOM 树向上查找,而不是向下。当一个元素声明了 animation-timeline: --foo,浏览器会从该元素自身开始,向上遍历祖先,寻找哪个祖先上定义了名为 --foo 的时间线。如果找到,就使用该时间线;如果找不到,动画就不会运行。

这种设计避免了命名冲突,也使得时间线的生命周期与定义它的元素绑定。但代价是,非父子关系的元素无法直接共享时间线,除非我们显式地提升时间线的作用域。

timeline-scope:将时间线提升到祖先作用域

timeline-scope 属性正是为了解决这个问题。它允许我们将一个命名时间线的作用域扩展到当前元素及其所有后代。语法上,timeline-scope 接受一个或多个 <dashed-ident>,即时间线名称,例如:

timeline-scope: --my-scroller;

当我们在一个祖先元素上设置 timeline-scope: --my-scroller 时,如果这个祖先的后代中存在一个名为 --my-scroller 的时间线,那么这个时间线的作用域就被提升到该祖先及其所有后代。这样,即使动画元素与滚动容器不是父子关系,只要它们共享同一个设置了 timeline-scope 的祖先,动画元素就能引用该时间线。

如果指定的名称在作用域内不存在,或者存在多个同名时间线,规范规定会创建一个不活跃的时间线,动画不会运行。这避免了歧义,但也意味着我们需要确保名称唯一。

timeline-scope 还有两个关键字值:noneallnone 是默认值,表示不改变时间线作用域;all 表示将所有后代定义的时间线都提升到当前作用域,但 all 可能带来性能开销,因为它需要遍历所有后代,且容易造成命名冲突,实际使用中应谨慎。

贯穿场景:页面级滚动进度条与多区域卡片动画

让我们用一个具体的页面结构来演示。假设页面布局如下:

<body>
  <header class="progress-bar"></header>
  <main>
    <section class="card">...</section>
    <section class="card">...</section>
    <section class="card">...</section>
  </main>
  <footer class="scroll-container">
    <div class="long-content"></div>
  </footer>
</body>

这里,滚动容器是 <footer class="scroll-container">,它包含一个很长的内容块,使得该容器可以滚动。进度条 <header> 和卡片 <section> 都不在滚动容器内部,而是它的兄弟或祖先的后代。我们希望进度条的宽度和卡片的透明度都根据滚动容器的滚动进度变化。

如果不使用 timeline-scope,这些元素无法引用滚动容器上的时间线。现在我们在 <body> 上设置 timeline-scope: --page-timeline,并在滚动容器上定义 scroll-timeline-name: --page-timeline。这样,--page-timeline 的作用域就被提升到 <body>,进度条和卡片都能引用它。

对应的 CSS 示意如下:

body {
  timeline-scope: --page-timeline;
}

.scroll-container {
  overflow-y: scroll;
  scroll-timeline-name: --page-timeline;
}

.progress-bar {
  animation: grow 1ms linear;
  animation-timeline: --page-timeline;
}

.card {
  animation: fade-in 1ms linear;
  animation-timeline: --page-timeline;
  animation-range: entry 0% entry 100%;
}

这里 animation-range 用于指定动画在时间线上的哪个区间生效,例如卡片在进入视口时开始淡入。

这个场景中,timeline-scope 让多个非父子关系的元素共享同一个滚动时间线,从而保证进度条和卡片的动画进度完全同步,因为它们都读取同一个时间线的当前进度。

查找机制与作用域细节

理解 timeline-scope 的关键在于命名时间线的查找机制。规范中定义了“命名时间线作用域与查找”的规则。当一个元素设置了 animation-timeline: --foo,浏览器会按照以下顺序查找:

  1. 从该元素自身开始,检查它是否定义了名为 --foo 的时间线(通过 scroll-timeline-nameview-timeline-name)。
  2. 如果没有,则向上遍历祖先元素,检查每个祖先是否定义了 --foo,或者是否通过 timeline-scope 将某个后代定义的 --foo 提升到了该祖先的作用域。
  3. 如果找到,则使用该时间线;如果找到多个,则使用最近的一个(但规范建议避免冲突)。

这里有一个重要细节:timeline-scope 本身并不定义时间线,它只是“提升”一个已存在的命名时间线。如果作用域内没有匹配的时间线,就会创建一个不活跃的时间线,动画不会运行。这意味着,timeline-scope 必须与实际的 scroll-timeline-name 配合使用,且名称必须一致。

此外,timeline-scope 只作用于命名时间线,不能用于 scroll()view() 函数创建的匿名时间线。匿名时间线是直接写在 animation-timeline 中的,没有名称,自然无法被提升作用域。

与传统 JS 滚动监听方案的对比

timeline-scope 出现之前,实现跨元素的滚动同步动画通常依赖 JavaScript。常见的做法是:

const scroller = document.querySelector('.scroll-container');
const progressBar = document.querySelector('.progress-bar');
const cards = document.querySelectorAll('.card');

scroller.addEventListener('scroll', () => {
  const scrollTop = scroller.scrollTop;
  const scrollHeight = scroller.scrollHeight - scroller.clientHeight;
  const progress = scrollTop / scrollHeight;
  progressBar.style.transform = `scaleX(${progress})`;
  cards.forEach(card => {
    const rect = card.getBoundingClientRect();
    // 计算进入视口的进度
  });
});

这种方案存在几个问题:

  • 性能:scroll 事件可能以每秒 60 次以上的频率触发,每次触发都执行 JavaScript,可能导致主线程过载,尤其在移动设备上。
  • 强制同步布局:在监听器中调用 getBoundingClientRect() 或读取布局属性,会强制浏览器提前计算布局,破坏渲染流水线的优化。
  • 复杂度:需要手动管理多个元素的动画状态,代码难以维护。

CSS scroll-driven animations 将动画进度计算移交给浏览器,浏览器可以优化滚动与动画的同步,甚至可以在合成器线程上处理,避免主线程阻塞。timeline-scope 则解决了多元素共享时间线的问题,使声明式方案适用于更复杂的布局。

下面是一个对比表格:

维度JS 滚动监听CSS scroll-driven + timeline-scope
性能高频触发,主线程负担重浏览器优化,可能运行在合成器线程
强制同步布局容易触发避免
代码复杂度高,需手动同步多个元素低,纯 CSS 声明
动画同步精度依赖 JS 计算,可能延迟同一时间线,天然同步
可维护性需处理事件监听器生命周期属性声明,易于理解
浏览器支持所有浏览器有限,需检查兼容性

需要注意的是,CSS 方案并非在所有场景都优于 JS。例如,当动画逻辑需要根据复杂的用户交互动态变化时,JS 可能更灵活。此外,CSS 方案的浏览器支持目前有限,需要提供降级方案。

数据流与状态流转

下面的 Mermaid 流程图展示了在 timeline-scope 提升作用域后,滚动容器的滚动偏移如何驱动多个动画元素:

flowchart TD
    A[用户滚动 .scroll-container] --> B[浏览器更新滚动偏移]
    B --> C[计算 --page-timeline 进度]
    C --> D[进度条动画更新]
    C --> E[卡片1动画更新]
    C --> F[卡片2动画更新]
    E --> G[触发 animation-range 进入/离开]
    F --> G
    G --> H[元素样式变化]

在这个流程中,滚动容器是唯一的时间线来源。浏览器内部维护时间线的当前进度,所有引用该时间线的动画都会根据这个进度更新,无需 JavaScript 介入。animation-range 可以控制每个动画在时间线的哪个区间内有效,例如卡片只在进入视口时才播放动画。

适用边界与浏览器支持

timeline-scope 并非万能。它的适用场景是:多个元素需要共享同一个滚动容器的进度,且这些元素与滚动容器没有直接的父子关系。如果动画元素本身就是滚动容器的后代,那么不需要 timeline-scope,直接引用即可。

如果滚动容器是页面根元素(即整个文档滚动),那么所有元素都是它的后代,理论上可以直接引用根元素上的时间线。但根元素是 <html>,设置 scroll-timeline-name 时需要注意,某些浏览器可能要求将滚动容器设置为 <body> 或某个 <div>,因此实际中仍可能需要 timeline-scope

浏览器支持方面,CSS scroll-driven animations 目前是 W3C 工作草案,尚未成为正式标准。根据 MDN 的标注,timeline-scope 不是 Baseline 特性,意味着它不能在所有主流浏览器中工作。截至 2026 年,Chrome 和 Edge 已经支持,Firefox 和 Safari 的支持情况需要查阅最新的兼容性表。在使用时,应通过 @supports 进行特性检测,并提供降级方案,例如在不支持时使用 JS 方案或静态样式。

另外,timeline-scope: all 虽然方便,但会提升所有后代时间线的作用域,可能造成命名冲突和性能开销,建议明确列出需要提升的时间线名称。

总结

timeline-scope 是 CSS scroll-driven animations 中一个小而关键的特性。它解决了命名时间线默认作用域的限制,使得非父子关系的元素也能共享同一个滚动时间线,从而实现跨区域的同步动画。理解它的查找机制和作用域规则,有助于我们在复杂布局中正确使用这一特性。在性能敏感的场景下,CSS 声明式方案比传统 JS 滚动监听更优,但需要权衡浏览器支持与降级成本。随着规范的发展和浏览器支持的完善,timeline-scope 有望成为实现滚动驱动动画的常用工具。

资料来源

  1. CSS Scroll-Driven Animations Level 1 - W3C Working Draft
  2. Scroll-driven animations - MDN Web Docs
  3. timeline-scope - CSS: Cascading Style Sheets | MDN
  4. scroll-timeline-name CSS property - CSS | MDN