以前想做滚动控制动画,基本只能用 IntersectionObserver + JS 去算滚动位置,再手动更新样式。现在 CSS 原生开始支持“滚动驱动动画”了,你可以让动画的进度直接绑定到滚动容器的滚动进度上。也就是说,滚动多少,动画就走多少,完全内置。
这篇文章不铺概念,直接上手三个可以立即用起来的场景:滚动进度条、视差图片、列表项渐入。拿完代码直接抄,顺便搞懂背后那两三个新属性。
核心:animation-timeline 和 scroll-timeline
最核心的是 animation-timeline,它可以让一个 CSS 动画不再以时间驱动,而是以“滚动进度”驱动。你把一个动画绑定到滚动容器后,动画的进度就取决于滚动条的位置。
语法很简单,比如:
.progress-bar {
width: 100%;
transform: scaleX(0);
transform-origin: left;
animation: grow 1s linear;
animation-timeline: scroll(root);
}
@keyframes grow {
to { transform: scaleX(1); }
}
这个例子中 scroll(root) 表示使用页面根元素的滚动进度。当滚动条从顶部滚到底部,动画就从 0% 走到 100%,滚动条往回滚,动画就反向。不需要 JS 通知,甚至不需要监听滚动事件。
注意:我写了 animation-duration: 1s 但实际在滚动驱动下,时间长短被忽略,因为动画进度由滚动进度控制。不过写 1s 也没什么影响,主要为了兼容不支持滚动时间线的浏览器,它们会当作普通动画跑一遍。
先做一个滚动进度条
最常见的应用就是页面顶部那个彩条。用上面的 .progress-bar 就能做。但是别把它放在固定定位?其实就直接放在 body 最顶部,作为固定定位,宽度 100%。
<div class="progress-bar"></div>
然后配合固定布局的样式,但这里咱们只关心核心代码:
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: #ff5f2e;
transform: scaleX(0);
transform-origin: 0%;
animation: auto-progress linear;
animation-timeline: scroll(root);
}
@keyframes auto-progress {
to { transform: scaleX(1); }
}
注意我给 animation 的 shorthand 里写的是 linear,没有写时长。实际上在 Chrome 121+ 中,你可以这样写:
animation: auto-progress linear both;
animation-timeline: scroll(root);
scroll(root) 是滚动驱动动画里的一个特殊时间线,表示页面根容器。
进阶:把某个容器的滚动作为时间线
如果页面里有一个可滚动的 div 而不是整个页面滚动,那就不能再用 scroll(root),需要把时间线绑定到那个容器上。
需要两步:先给容器设置 scroll-timeline 名称,然后在动画里用 animation-timeline: 名字。
.scroll-wrap {
overflow-y: auto;
scroll-timeline: container-scroll; /* 自定义时间线名字 */
}
.box {
animation: fade-up linear;
animation-timeline: container-scroll;
}
这样定义的容器,卷动到什么位置,里面的动画就走多少。注意,scroll-timeline 这个属性需要设置在滚动容器上,而不是子元素。
实际案例:视差卡片
很多页面喜欢做“滚动视差”,就是不同元素滚动速度不一样。用滚动驱动动画可以做,不过核心思路是把动画的关键帧映射到滚动进度。
例如一个图片卡片,希望它滚动时从上方滑入,并且稍微改变透明度:
.profile-card {
animation: parallax linear;
animation-timeline: view();
}
@keyframes parallax {
0% {
transform: translateY(100px);
opacity: 0;
}
50% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-20px);
opacity: 0.8;
}
}
这里用了一个新的时间线关键词 view()。它表示元素进入视口的过程。默认情况下,view() 的时间线范围是元素从进入视口(下边缘刚进入)到完全离开视口(上边缘完全超出)。所以 0% 对应元素刚出现,100% 对应元素完全滚出。
效果就是:卡片在滚动时,自己会从滑入、稳定、最后滑出。这比用 JS 调来调去顺滑得多。
view() 时间线的偏移控制
你可能觉得默认范围太大了,希望只在元素从下边进入视口到居中这一小段播放。比如想做一个“淡入上浮”效果,希望滚动到元素出现在屏幕中间时已经结束动画。
可以给 view() 传入参数:
.fade-in {
animation: rise linear;
animation-timeline: view(block 80% 20%);
}
@keyframes rise {
from { opacity: 0; transform: translateY(60px); }
to { opacity: 1; transform: none; }
}
这里的 block 80% 20% 分别表示:元素与视口的相交范围?大致是说块轴方向,从元素下边进入视口 80% 高度时开始,到元素上边到达视口 20% 高度时结束。有点绕,但实际你可以多多调试数值。
其实不用完全理解每个参数,只要知道你可以控制动画的起点和终点,让它更匹配你的设计。
再复杂一点:多个动画共享同一个时间线
如果页面里好几个元素希望同步滚动进度,比如标题和配图同时移动,只需要给每个元素设置同样的 animation-timeline 名称,或者都用 view(),它们的进度是独立的。但如果你希望多个元素共用同一个滚动容器的进度(比如所有元素都跟随页面滚动),那就用 scroll(root) 或者显式定义一个自定义 scroll-timeline 在父容器上。
body {
scroll-timeline: page-timeline;
}
.header {
animation: move-left linear;
animation-timeline: page-timeline;
}
.footer {
animation: move-right linear;
animation-timeline: page-timeline;
}
这样无论页面有多少个子元素,它们都跟随着根滚动容器的进度变换。
实际项目中的降级方案
滚动驱动动画在 Chrome 115+ 默认支持,包括 Android Chrome。Safari 和 Firefox 最近也在推进中。对于不支持的浏览器,动画会直接不执行?其实更稳妥的做法是把它当成一个渐强增强功能。
比如我上面写的淡入效果,如果浏览器不支持滚动时间线,那么 animation-timeline 属性会被忽略,动画就不会播放。这种情况下元素一开始就是透明且位移 60px,那不是看不见了?
所以需要给动画一个相反的 fallback:默认先让元素可见,然后设置 animation-timeline 之后再让动画来控制可见性。我常用的手段是这样的:
.fade-in {
opacity: 1;
}
@supports (animation-timeline: view()) {
.fade-in {
opacity: 0;
animation: rise linear;
animation-timeline: view();
}
}
用 @supports 检测是否支持 animation-timeline,只有支持时才应用“初始隐藏”和滚动动画。这样旧浏览器就会直接显示内容,不至于出大问题。
但千万别忽略了 scroll-behavior
如果你给滚动容器设置了 scroll-behavior: smooth,那滚动驱动动画依然有效,但当你点击锚点跳转时,动画会跟着滚动条平滑过渡,看起来非常自然。不过要注意,如果你用 JS 的 scrollTo 并且传入 behavior:"instant",动画会瞬间跳到对应进度,这是符合预期的。
一个完整的“新闻卡片列表”例子
我做一个没有样式的列表,但核心动画逻辑是完整的。
<article class="news-list">
<div class="news-item" data-index="1">
<h3>标题一</h3>
<p>内容……</p>
</div>
<div class="news-item" data-index="2">
<h3>标题二</h3>
<p>内容……</p>
</div>
<div class="news-item" data-index="3">
<h3>标题三</h3>
<p>内容……</p>
</div>
</article>
然后给每个 .news-item 设置 view() 动画,但是希望它们按照顺序依次出现?其实不用配置交错,因为每个元素自己滚动进入视口,天然就是按顺序出现的。唯一要调节的是让元素到视口中间时动画刚好结束。
.news-item {
animation: slide-up linear;
animation-timeline: view(block 90% 10%);
}
@keyframes slide-up {
from { transform: translateY(30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
这样滚动时,每个卡片进入视口 90% 高度时开始上滑,到元素顶部到达视口 10% 高度时完全结束。看起来就是一串卡片依次滑入,虽然没有任何 JS 参与。
一些奇怪的现象及解释
我实际测试的时候发现,如果父容器有 transform 或者 filter,可能会影响 view() 的计算,导致动画时机不准。原因大概是因为这些属性会创建包含块,改变了视口的位置计算。
解决办法是把滚动动画放到外层容器上,避免内部元素拥有额外的 transform 属性。如果非要 transform,就改用 scroll() 时间线,通过父容器滚动来驱动它。
还有一个坑:如果你在 keyframe 中设置了 opacity: 0,并且 animation-fill-mode 默认是 none,那在动画开始前元素是可见的,动画结束后突然消失?滚动驱动动画的进度不在 0% 到 100% 之间时,元素会使用未动画的状态。所以如果想让元素“只出现在滚动区间中”,需要加上 animation-fill-mode: both。
.news-item {
animation: slide-up linear both;
animation-timeline: view();
}
但这样它就会在滚动区间外保持 0% 或 100% 的状态,对于后面要滚出的元素,它可能会一直保持 100% 的透明状态直到结束。实际需求看你怎么设计。
性能到底怎么样?
滚动驱动动画是浏览器原生实现的,它在合成器线程上运行,不会在滚动时触发主线程重排。这意味着它的性能远好于传统的 scroll 事件 + JS 做样式变更。但注意,如果你在动画关键帧中改变了 top / width / box-shadow 这些属性,还是会引起布局/绘制,所以尽量只使用 transform 和 opacity,这样才能真正流畅。
写到最后
CSS 滚动驱动动画并不是想让你抛弃 GSAP 或者 Lenis,但它解决了一个真实痛点:简单视差、滚动进度、进入视口动画不再需要写一大堆 JS。如果你今天第一次看到这东西,建议先照着上面的代码复制到本地试试,滚动一眼就能体会到它的神奇。习惯了之后,你会发现自己越来越不需要引入那些庞大的滚动库了。

