以前做一个“阅读进度条”,哪个前端不是先写个 addEventListener('scroll'),然后计算 scrollTop / (scrollHeight – clientHeight),再改一个元素的 width。现在呢?Chrome 115 之后,你只要用 CSS 的 scroll-timeline 和 animation-timeline,就能把动画和滚动位置天然绑定。没错,不用 JS,甚至不用一行业务逻辑。
先搞清楚这俩新东西是干嘛的
简单说,scroll-timeline 允许你把某个滚动容器(比如页面的根元素,或者一个 overflow:scroll 的 div)定义成一条“时间线”。然后你写的 CSS 动画就不再以时间而是以滚动进度来驱动。
好,那另一个 view-timeline 又是啥?它是跟元素在视口里的可见位置挂钩的。比如一个图片从底部出现到完全进入视口,它会提供一个进度值,你拿这个进度可以做入场动画。这两个东西组合使用,可以告别绝大多数用于滚动监听的 JS。
先搞一个最简单的页面滚动进度条
假设你把整个 body 当作滚动容器,你想在页面顶部显示一条红色进度条。以前可能是这样的:
// 以前我们写一堆 JS
window.addEventListener('scroll', () => {
const h = document.documentElement;
const pct = h.scrollTop / (h.scrollHeight - h.clientHeight);
progressBar.style.width = pct * 100 + '%';
});
现在你只需要一个 div 放在 body 开头,比如:
<div class="progress"></div>
然后 CSS 里定义好动画和时间线:
/* 先注册根元素滚动为一条时间线 */
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 4px;
background: #f00;
transform-origin: left center;
animation: grow linear both;
animation-timeline: scroll(root block); /* 指定滚动时间线 */
}
注意到关键一行 animation-timeline: scroll(root block) 里的 root 表示用根滚动容器(也就是页面本身),block 表示按块方向滚动(纵向)。然后动画会随着滚动的进度走 from 和 to。你根本不需要知道滚动距离,浏览器自己在内部把滚动位置映射成 0 到 100% 的进度。
把进度条改成渐变、加个发光也随你
你会发现这个动画是可以叠加任何 CSS 属性变化的。比如我想让进度条在滚动时从左到右显示,而且颜色从蓝到绿,再带个模糊变化:
@keyframes progress-anim {
0% { width: 0%; background: #0af; filter: blur(0px); }
50% { width: 50%; background: #0f0; filter: blur(1px); }
100% { width: 100%; background: #f80; filter: blur(0px); }
}
.progress {
height: 6px;
animation: progress-anim linear both;
animation-timeline: scroll(root block);
}
这里我用了 width 属性,你可能会问直接给 width 做动画会不会触发重排?会,但在这个场景里影响不大。不过我建议你改用 scaleX,流畅度更好。
案例二:图片渐显入场,不用IO了
以前我们用 IntersectionObserver 来做图片懒加载入场,现在可以考虑用 view-timeline 实现。假设你有一张图片:
<img src="photo.jpg" class="fade-in" alt="一张图">
CSS 里这样写:
@keyframes fadeSlide {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
view-timeline-name: --item-in;
view-timeline-axis: block;
animation: fadeSlide linear both;
animation-timeline: view(--item-in);
}
解释一下,view-timeline-name: --item-in 给这个元素自己定义了一条视图时间线。它的进度是 0% 的时候元素还没进入视口,100% 的时候元素完全离开视口?不对,这里有个细节:默认的 view-timeline 的范围是元素从进入视口到完全离开视口,所以如果你直接用它,动画会在元素离开视口时才结束。如果你想让它只在“进入视口”这一小段播放,可以通过 animation-range 调整。
.fade-in {
view-timeline-name: --item-in;
animation: fadeSlide linear both;
animation-timeline: view(--item-in);
animation-range: entry 0% entry 50%; /* 在进入视口的前半段完成动画 */
}
还可以写成覆盖 25% 到 75% 的区间。这个 animation-range 非常灵活,它就像截取时间线的一段来映射动画。你甚至可以这样:
animation-range: entry 10% exit 50%;
意思是元素进入视口 10% 到离开视口 50% 这个区间内,动画从 0 走到 100。这个能力以前根本不敢想。
案例三:滚动驱动某个元素旋转 360 度
我见过有人用这个做了一个小火箭,页面滚到底部的时候转一圈。其实更像是一个装饰性的图形。代码异常简单:
.spinner {
width: 50px;
height: 50px;
background: conic-gradient(#f00, #ff0);
border-radius: 50%;
animation: spin linear both;
animation-timeline: scroll(root block);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
当你滚动页面时,这个小圆球就会跟着滚动进度一直转。如果你想让它在滚动到某个区域才开始转,可以用 animation-range: cover 0% 100% 或者用 view() 配合容器。
几个实际项目里必须注意的坑
先说兼容性:目前 Chrome 和 Edge 都能用,Safari 和 Firefox 还没影呢。所以你要么把功能作为“增强”,要么用 JS 检测支持情况再 fallback。我一般用 @supports (animation-timeline: scroll()) 包一层,不支持无非就是没动画,不影响阅读。
还有一个大坑:当你给一个元素配上 animation-timeline 时,该元素不能再用普通的 animation-duration 来控制时间。因为时间线已经被滚动接管了。如果你不小心写了 animation: spin 2s linear both,这样时间线就变成 2 秒,而不是滚动。所以记得省略时间,或者单独写 animation-duration: auto。
还有如果你把滚动容器设置成某个 div,而不是根元素,那必须给那个 div 设置 overflow: scroll 或者 overflow-y: scroll,并且给它一个高度,否则它根本不会滚动。
.scroll-container {
overflow-y: scroll;
height: 500px;
scroll-timeline-name: --container-scroll;
scroll-timeline-axis: block;
}
.child {
animation: fancy both;
animation-timeline: --container-scroll;
}
这里的 scroll-timeline-name: --container-scroll 就是把当前容器的滚动命名为一个自定义时间线。子元素就可以用 animation-timeline: --container-scroll 来引用。
用 view-timeline 做一个吸底菜单的自动隐藏
有些页面会在你向下滚动时隐藏导航栏,向上滚动时显示。以前这要用 JS 判断滚动方向。虽然现在 scroll-timeline 不直接给你方向判断,但你可以利用它结合两个关键帧让它“变相实现”。比如你在页脚放一个元素,当它“即将进入视口”时把导航栏推出屏幕,当它“即将离开视口”时拉回来。但这还是有点绕,不如 JS 直白。所以别什么都硬用 CSS,适合的才用。
给文章来个真·进度条,而且不碍事
最后我给你一个能直接用在博客上的完整代码。比如在 body 最上面放一个 div,然后在 CSS 里滚动 root 时间线,再配合一个非常实在的动画:
@keyframes reading-progress {
0% { --n: 0; }
100% { --n: 100; }
}
@property --n {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.progress::before {
counter-reset: n var(--n);
content: counter(n) '%';
position: absolute;
right: 8px;
top: -16px;
font-size: 12px;
color: #666;
}
这个有点炫技了,它用计数器把滚动百分比显示在进度条尾巴上。但实际演示中,你直接在控制台看看 --n 变没变就行。
当你真的想放弃 JS 时,想想可访问性
滚动驱动动画大部分时候是装饰性的,所以要注意用户是否动了“减少动态效果”的系统设置。好在 CSS 有 prefers-reduced-motion,你可以这样处理:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
animation-timeline: none !important;
}
}
这样那些不喜欢动画的用户就不会被晃到。
我自己的实际测试感受
我在一个长页面上做了四个不同元素的滚动动画,整个页面滚动时的流畅度非常棒,没有出现闪烁。相比以前用 JS 批量更新 requestAnimationFrame,这个方案没有主线程压力,因为合成器直接把滚动位置传给动画了。但是要特别注意,如果你在动画里改的是 width、top 这类几何属性,浏览器可能不会走合成器,依然会产生重排,建议尽量对 transform 和 opacity 做动画。
总结一下
scroll-timeline用于监听滚动容器的滚动进度,view-timeline用于监听元素自身在视口中的可见变化。- 把
animation-timeline指定为scroll(root block)或自定义的--timeline即可让动画由滚动驱动。 - 用
animation-range控制动画对应时间线的哪一段,比如entry 0% entry 50%来让元素一进入画面就完成动效。 - 兼容性并没有全面覆盖,但你可以放心用
@supports做增强,不影响旧浏览器的阅读。
如果你还在用 JS 写滚动进度条,真的建议试试这玩意儿。刚开始有点不习惯,但用熟了以后你会回来叫爽的。顺手给项目加个平滑的滚动指示器,几行代码就搞定,还不用监听 scroll,多惬意。
完。

