滚动动画被锁在父容器里我折腾了三年,今天 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-scope 和 view() 在 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 给老浏览器兜底。
评论区
登录后可评论。