滚动动画被锁在父容器里我折腾了三年,今天 CSS 自己会找祖先了——timeline-scope 把滚动时间线彻底透传了

说实话,滚动动画我折腾了三年,一直被一个问题卡着:动画只能绑在当前元素的滚动容器上,不能跨容器。

什么意思呢?比如我有一个卡片列表,卡片在 .container 里,container 本身有滚动区域。如果我想让卡片里的某个色块跟着外层页面的滚动而变化——对不起,CSS 做不到。

直到 timeline-scope 出来之后,我才发现这个问题被彻底解了。


先说旧方案的坑

在没有 timeline-scope 的时候,animation-timeline: scroll() 只能认当前元素的最近滚动祖先。举个例子:

<div class="outer-scroll">    <!-- 页面级滚动容器 -->
  <div class="card">
    <div class="progress-bar"></div>
  </div>
</div>

如果想让 .progress-bar 跟着外层 .outer-scroll 的滚动走,CSS 写不了。.card 的最近滚动祖先就是 .outer-scroll,这没问题——但如果结构再深一层呢?

<div class="outer-scroll">
  <div class="wrapper">        <!-- 没有滚动条,但挡在中间 -->
    <div class="card">
      <div class="progress-bar"></div>
    </div>
  </div>
</div>

.card 的最近滚动祖先还是 .outer-scroll,但 .progress-bar 呢?它的最近滚动祖先是 .card,而不是 .outer-scroll。于是动画绑错了位置,你以为在跟着页面滚,实际绑的是卡片自己的滚动状态。

这个问题以前只能靠 JS 或者把 HTML 结构扁平化来解决。


timeline-scope 把这件事说清楚了

timeline-scope 的作用很简单:告诉子元素「去用哪个祖先的滚动时间线」。

.wrapper {
  timeline-scope: --main-scroll;
}

.outer-scroll {
  scroll-timeline: --main-scroll;
}

.progress-bar {
  animation: grow linear both;
  animation-timeline: --main-scroll;   /* 显式声明用哪个时间线 */
}

@keyframes grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

关键是 .wrapper 上的 timeline-scope: --main-scroll——它把 .outer-scroll 上定义的 --main-scroll 滚动时间线,「穿透」到子元素可用的范围里。

所以 .progress-bar 找时间线的时候,先看自己有没有 timeline-scope 声明,没有就向上找,找到 .wrapper 的时候发现 --main-scroll 被标记为可用,就用了外层的滚动时间线。


view() 函数的正确用法

view() 函数是另一个关键,它让你把「元素在视口中的可见程度」变成动画时间线。

.card {
  animation: fade-in linear both;
  animation-timeline: view();  /* 元素进入/离开视口时触发 */
}

@keyframes fade-in {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

view() 默认基于最近滚动祖先。但配合 timeline-scope,你可以让它基于外层滚动:

.outer-scroll {
  scroll-timeline: --page-scroll;
}

.card-list {
  timeline-scope: --page-scroll;
}

.card {
  animation: slide-in linear both;
  animation-timeline: view(timeline: --page-scroll);
}

@keyframes slide-in {
  from { opacity: 0; transform: translateY(40px); }
  to { opacity: 1; transform: translateY(0); }
}

这里 view(timeline: --page-scroll) 的意思是:这个卡片的进入/离开动画,绑的不是自己容器的滚动,而是外层 --page-scroll 定义的滚动时间线。


三个常见坑

坑1:timeline-scope 的值是自定义标识符,不是 CSS 属性

/* 错误:写了属性名 */
.wrapper {
  timeline-scope: scroll;  /* 不生效 */
}

/* 正确:自定义名字,和 scroll-timeline 的 --xxx 对应 */
.wrapper {
  timeline-scope: --my-timeline;  /* 正确 */
}

scroll-timeline-name 也可以直接写名字,不用 -- 前缀:

.outer-scroll {
  scroll-timeline-name: main-scroll;  /* 直接声明名字 */
}

.wrapper {
  timeline-scope: main-scroll;        /* 引用同名 */
}

两种写法等价。

坑2:view() 不支持 scroll() 的方向参数

scroll() 可以指定 scroll-direction: vertical | horizontal,但 view() 不行,它始终基于元素在滚动方向上的可见比例。

坑3:Firefox 还没支持

截至 2026 年 8 月,timeline-scopeview() 在 Firefox 上还需要开启实验 flag。如果项目要兼容 Firefox,这两条属性暂时别用在核心功能上。


什么时候该用

如果你有这几种场景,timeline-scope + view() 值得优先考虑:

  • 卡片列表进入视口时的入场动画(不用 Intersection Observer)
  • 嵌套滚动容器里,子元素需要跟着祖先滚动做同步动画
  • 多步引导动画,希望每个步骤的进度和页面滚动绑定

对于简单的「滚动时让元素停在原地」这种需求,position: sticky 仍然是最稳的方案。timeline-scope 的价值在于时间线共享——多个元素、不同层级,都绑到同一个滚动时间线上。


下一步

在你的项目里找一个有嵌套滚动结构的页面,把其中一个子元素的动画换成 animation-timeline: scroll() 配对应容器的 scroll-timeline-name,感受一下它和 JS 方案的区别。如果遇到结构卡住的情况,记得往父级加 timeline-scope 把时间线透传下去。

浏览器兼容性在 Chrome/Safari 上已经比较稳了,生产可用。Firefox 用户可以先做 feature detect:

@supports (timeline-scope: --x) {
  /* 渐进增强 */
}

这个思路和当年 Flexbox / Grid 的渐进增强一样——先跑通主流浏览器,再用 @supports 给老浏览器兜底。

评论区

0 条评论

登录后可评论。

小鹿·界面实验室 396 阅读