CSS 滚动动画被困在父子关系里三年了——`scroll()` + `timeline-scope` 把这个作用域围墙彻底拆了

以前给一个滚动容器里的子元素加动画,我要么把它写成滚动容器的子元素,要么在 JS 里算好滚动偏移量再传给 CSS——因为 CSS 动画的时间轴只认「直系父子」关系,孙子元素想响应祖先的滚动?浏览器直接当没看见。我被这个「作用域围墙」折腾了三年,现在 scroll() 函数加上 timeline-scope,把这件事彻底变了——CSS 自己会顺着 DOM 树往上找祖先的滚动容器,不管隔了多少层。

问题:CSS 滚动动画为什么被困在「父子关系」里

CSS scroll-driven animations 的原理说起来很简单:给一个元素绑定滚动时间轴,它就跟着某个滚动容器的滚动进度跑动画。但这里有个隐含前提——浏览器默认只从「最近的滚动祖先」取时间轴。

举个例子:你在一个 .container(可滚动)里放了一个 .wrapper(不可滚动),.wrapper 里才是需要动画的 .card。如果直接写 animation-timeline: scroll(nearest).card 会去找最近的滚动容器——也就是 .container。但如果你想让它响应的是页面根视口的滚动,而 .container 本身也在页面上,这个「就近原则」就成了限制。

为什么浏览器要这么设计?因为 CSS 的作用域模型是基于 DOM 树的,「就近祖先」是最容易确定的作用域边界。但这在真实项目里很不方便——组件封装之后,动画逻辑和滚动容器往往不在同一个 DOM 分支里。

方案一:scroll() 函数的四个取值,把滚动容器选择权还给你

Chrome 115+ 开始,scroll-driven animations 支持用 scroll() 函数直接指定滚动容器,不再依赖 scroll-timeline 的命名时间轴。

/* 默认行为:找最近的滚动祖先 */
animation-timeline: scroll();

/* nearest:最近的滚动祖先 */
animation-timeline: scroll(nearest);

/* root:文档根视口(浏览器窗口) */
animation-timeline: scroll(root);

/* self:元素自身就是滚动容器(自己滚自己) */
animation-timeline: scroll(self);

这四个取值解决了大多数「想指定祖先滚动容器」的场景。比如一个固定导航栏,你只想让它跟随页面整体的滚动——用 scroll(root) 就行:

.navbar {
  animation: shrink linear;
  animation-timeline: scroll(root);
}

@keyframes shrink {
  from { padding: 20px 0; }
  to { padding: 10px 0; font-size: 14px; }
}

不需要把导航栏写成 <body> 的直接子元素,它即使嵌套在某个 <div id="app"> 里,也能正确响应根视口的滚动。

scroll() 有一个限制:它只能找「祖先滚动容器」,不能找「祖先的祖先」。如果滚动容器在更远的地方,scroll(nearest) 找的是最近的那个,不是你指定的那个。

方案二:timeline-scope,把时间轴的作用域从「就近」变成「可传递」

timeline-scope 是 Chrome 122+ 支持的属性,它的作用是:把当前元素上定义的具名滚动时间轴,暴露给它的后代元素使用

/* 在祖先容器上定义时间轴并扩大作用域 */
.scrollable-container {
  scroll-timeline: --my-scroll-timeline block;
  timeline-scope: --my-scroll-timeline;
}

/* 子孙元素直接引用这个名字,不管 DOM 层级多深 */
.deep-nested-card {
  animation: fade-in linear;
  animation-timeline: --my-scroll-timeline;
}

这里的关键是:scroll-timeline 定义了时间轴的来源(哪个滚动容器、哪个方向),timeline-scope 把这个时间轴的名称传播给所有后代。所以 .deep-nested-card 不需要是 .scrollable-container 的直接子元素——哪怕隔了五六层,照样能用这个时间轴。

一个实战场景:卡片网格布局,卡片在 .grid 内部,但滚动容器是外层的 .page。用 timeline-scope 可以让所有卡片共享同一个页面滚动时间轴:

.page {
  scroll-timeline: --page-scroll block;
  timeline-scope: --page-scroll;
}

.grid .card {
  animation: card-entrance linear;
  animation-timeline: --page-scroll;
  animation-range: 20% 80%;
}

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

这样每张卡片在整个页面滚动到 20%~80% 的区间内,透明度从 0 过渡到 1,入场节奏和滚动位置精确绑定,不再受 DOM 层级限制。

方案三:view() timeline,让元素自己决定「在视口中的哪段区间」跑动画

除了基于滚动容器的时间轴,CSS 还支持基于「元素自身在视口中的可见程度」的时间轴——这就是 view() timeline。

.scroll-animating-element {
  animation: parallax linear;
  animation-timeline: view();
}

view() 会根据元素进入视口的程度(从完全不可见→完全可见)生成 0%~100% 的进度。最常见的用法是配合 animation-range 限制生效区间:

.hero-section {
  animation: scale-up linear;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

@keyframes scale-up {
  from { transform: scale(0.9); opacity: 0.6; }
  to { transform: scale(1); opacity: 1; }
}

这段代码的意思是:当这个元素从「刚进入视口」到「覆盖视口 40%」的过程中,执行 scale-up 动画。对滚动视差、分段入场动画特别有用。

三个方案怎么选

scroll() 适合「找最近的滚动祖先或根视口」的场景,不需要命名,直接指定容器类型,最轻量。

timeline-scope 适合「组件封装后,动画元素和滚动容器不在同一个 DOM 分支」的场景,通过命名时间轴把作用域透传下去,灵活度最高。

view() 适合「动画进度由元素自身在视口中的位置决定」的场景,比如入场动画、滚动视差,不需要明确指定滚动容器。

三个方案可以组合。比如一个卡片网格,卡片入场用 view() 做入场动画,同时卡片内部的进度条用 timeline-scope 共享祖先滚动容器的进度——一套 CSS,滚动驱动全搞定,JS 只剩一个作用:提供数据。

下一步:从一个进度条开始试

如果你现在项目里还在用 window.addEventListener("scroll", ...) 做滚动动画,找一个最简单的场景——比如页面阅读进度条——改成 scroll(root)。三行 CSS,不用任何 JS,刷新页面就能看到效果。从这个小口子进去,把整个滚动驱动的思路换掉,后面的复杂动画都会简单得多。

评论区

0 条评论

登录后可评论。

AI 论文日报 227 阅读