前情提要:
告别 z-index 内战,原生 popover 属性白送 Top Layer、自动关闭与焦点管理
z-index: 99999 输给 z-index: 2?原生弹窗的 Top Layer 排位战:后进者居上、看得见却点不着
气泡的位置问题解决了,动效还开着 scroll 监听呢?
先看下面这段代码:
js
const bar = document.getElementById('bar');
addEventListener('scroll', () => {
const max = document.documentElement.scrollHeight - innerHeight;
bar.style.transform = `scaleX(${max ? scrollY / max : 0})`;
}, { passive: true });
看着没啥问题,但有几个问题藏在里面:scroll 事件是主线程的任务,页面一忙它就排队;事件本身高频触发,就算 passive 也得每次重算布局值;要是想做滚到哪播到哪的动画,还得自己写插值。
CSS 滚动驱动动画(Scroll-Driven Animations)干的事就一件:把时间轴和滚动位置焊死,动画不再走时间,改走滚动进度。JS 一行不用写。
一、把 timeline 换掉就行
任意一个 CSS 动画,加一行:
css
#bar {
animation: grow linear;
animation-timeline: scroll(); /* 动画进度 = 滚动进度 */
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
注意一个细节:这里连 duration 都没写(shorthand 里省略即 0s),动画照样全程跟滚动------时间轴模式下,进度由 timeline 决定,duration 说了不算。和传统动画有点不一样。
先看下支持度:

二、滚动到哪,进度就该到哪
拿 document.getAnimations() 直接读动画对象的 getComputedTiming().progress,对比滚动位置:
csharp
=== T1 scroll() 进度条与页面滚动联动 ===
页面可滚距离: 1725px
scrollTo(25%) => #barCss 动画 progress = 0.25
scrollTo(50%) => #barCss 动画 progress = 0.5
scrollTo(75%) => #barCss 动画 progress = 0.75
滚 25% 就是 0.25,滚 50% 就是 0.5------时间轴和滚动位置严格线性挂钩,没有任何插值损耗。
绑页面?其实绑的是最近可滚祖先
scroll() 不带参数时,官方语义是「最近的可滚动祖先」。测试页面里嵌了一个 180px 高的内层滚动容器,容器里放一个旋转方块,同样 animation-timeline: scroll():
ini
=== T2 scroll() 绑的是「最近可滚祖先」,不是页面 ===
页面 scrollTo(0) 不动,内层 scroller 滚 210/420px => #boxInner progress: 0 -> 0.484
同一时刻页面进度条 progress = 0(内层滚动没带偏它:两条时间轴互不相干)
内层滚到底(420/420px) => #boxInner progress = 0.968(只由内层 scroller 驱动到底)
方块只认内层容器,页面滚上天它都不转 ;页面顶部的进度条也完全不受内层滚动影响。这个默认行为踩一次就明白:想让组件跟整页滚动走,组件又恰好嵌在某个滚动容器里,就得写 scroll(nearest) 之外的显式参数,或者用 timeline-scope 把时间轴提到祖先层。
view():元素自己就是时间轴
scroll() 以滚动容器为基准,view() 则以元素自己在视口中的位置为基准------元素滚进来多少,动画就播多少。渐现卡片实测:
scss
=== T3 view() 渐现卡片:进度=元素在视口中的位置 ===
scrollTo(卡片1刚露头, y=597) => progress = 0.013
scrollTo(卡片1到视口中部, y=987) => progress = 0.523
scrollTo(卡片1接近顶边, y=1207) => progress = 0.811
再配一个 animation-range 控制播放区间。把卡片 3 的动画范围压成 entry 0% cover 40%------从刚露头播到「盖住视口 40%」就收工:
erlang
=== T4 animation-range: entry 0% cover 40%(时间轴压缩) ===
卡片3刚露头(顶边进入视口 50px / 全轴 764px = 6.5%)=> ranged progress = 0.164
自洽验算:轴位置 6.5% ÷ range 区间 40% = 0.164 ------ 与实测 0.164 吻合
range 把『播完点』从覆盖 100% 提前到 40%,同一滚动位置下动画进度被放大 2.5 倍
卡片刚露头才走了全轴的 6.5%,动画进度却已经 16.4%,range 把时间轴压缩到 40% 区间,进度按比例放大,0.164 分毫不差。想让动画早点动完,不用改 keyframes,改 range 就行。
三、堵死主线程,谁还活着?
前面的都是 能用 ,为什么值得删掉 scroll 监听?
把文章开头那个 JS 进度条(橙色)和 CSS 时间轴进度条(绿色)放到同一个页面里,两条一起跑。然后同步死循环把主线程堵死 1.5 秒,堵着的时候用 CDP 直接发滚轮事件滚 1300px(合成器线程的事,不经过主线程 JS),看两条的反应:
scss
=== T6 王炸:堵死主线程 1.5s,谁还活着? ===
[堵前] scrollTo(345px) => CSS条 progress=0.2 | JS条 transform=matrix(0.2, ...) | scroll事件计数=9
[堵中] 发 wheel(0, 1300)(合成器滚动,主线程事件循环被 while 卡死)
[解锁瞬间·渲染前] scrollY=345(合成器已滚完)| scroll事件计数=9(堵前 9,一次都没多)
[scroll事件回调第一行] 采样:CSS条 progress=0.953623188405797 | JS条 transform=matrix(0.2, ...)(本回调才去更新它)
[稳定后] scrollY=1645 | CSS条 progress=0.954 | JS条 transform=matrix(0.953623, ...)
[稳定后] scroll事件计数 9 -> 10(1300px 滚动只补发 1 次合并事件)
1. 主线程堵死期间,scroll 事件一次都没派发。 1300px 的滚动,JS 眼里完全不存在。解锁后也只补发了 1 次 合并事件------中间过程全丢。主线程一忙,事件驱动的动画直接失联,恢复时从起点直接瞬移到终点。
2. 解锁瞬间的快照更狠:连 scrollY 读出来都是旧值 345。 主线程冻结期间整个 JS 世界停摆------滚动在物理上发生了,DOM 里却查无此事。
3. scroll 事件回调的第一行,做了个差分采样。 补发的那次事件执行时,第一行代码先读两个进度条的状态:
ini
此刻 CSS 条 progress = 0.953623188405797(已精确到位 = 1645/1725)
此刻 JS 条 transform = matrix(0.2, ...)(还冻在堵前,等着这个回调去更新它)
同一个时刻,同一个事件,CSS 动画已经走到位了,JS 条还等着这个事件来救。
说明了CSS 滚动驱动动画的状态更新根本不经过事件队列,滚动位置在合成器线程直接换算成动画进度,主线程堵成什么样都不关它的事。
数据不太好拿, Page.captureScreenshot 在主线程堵死时被渲染进程排队,拍到的是解锁后的帧;换 CDP 的 startScreencast 持续推帧,一样被堵------连调试器都看不到主线程卡死时的画面
最后是靠事件回调第一行这个时间缝隙把差分抓到的。
可以拿到demo(文末口令)之后自己验证,点【堵主线程】按钮,立刻滚动------绿条跟手,橙条僵住,1.5 秒后橙条猛地跳到头。自己看一遍验证会更加直观。
四、面试题
1. animation-timeline: scroll() 和 view() 的区别?
答:scroll() 以滚动容器 的滚动进度为时间轴(scroller 上滚动多少,动画进度多少),默认绑「最近可滚祖先」,可传参数指定 root/nearest/self 和滚动轴;view() 以元素自身在视口中的交越进度为时间轴(元素滚进/滚出视口的过程就是播放区间),适合「滚到哪显现到哪」的进场动效。
2. 为什么 CSS 滚动驱动动画比 scroll 事件 + JS 更流畅?
答:两条路径不同。scroll 事件派发、回调执行、样式写入全在主线程,主线程忙时事件被合并甚至丢失,动画跟着卡;scroll-driven animation 的进度由合成器线程按滚动位置直接计算,不经过主线程任务队列。实测主线程被死循环堵死 1.5 秒:scroll 事件零派发、解锁后 1300px 滚动只合并成 1 次事件,而 CSS 动画进度已精确对齐滚动位置(0.9536 = 1645/1725)。
3. animation-timeline 生效后,animation-duration 还有用吗?
答:对纯滚动时间轴,duration 不再决定播放节奏------进度完全由滚动位置映射,实测不写 duration(即 0s)动画照样全程驱动。duration 在需要「时间轴 + 时间缓动」混搭(如 animation-duration 配合 repeat / delay 控制循环段)或退化为普通时间动画(浏览器不支持时)才有意义。生产环境要考虑 Firefox 目前未默认支持,需保留 JS 降级方案。
总结
scss
滚动驱动动画 · 知识图谱
─────────────────────────────────────────────
时间轴类型
scroll() 滚动容器进度驱动(默认最近可滚祖先!)
view() 元素与视口交越进度驱动
区间控制
animation-range entry / exit / cover / contain
例:entry 0% cover 40% → 时间轴压缩 2.5x
线程归属
scroll 事件 主线程(忙时失联、合并丢帧)
scroll-driven 合成器线程(滚动位置直接算,实测堵死主线程仍跟手)
支持度(截至发稿)
Chrome/Edge 115+ ✓ Safari 26+ ✓ Firefox ✗(唯一缺口,留 JS 降级)
─────────────────────────────────────────────
scroll 监听器是你亲手把动画按进主线程的,CSS 时间轴只是把它捞出来。
参考
- MDN:CSS scroll-driven animations(animation-timeline / animation-range)
- Chrome for Developers:Scroll-driven animations(Bramus 系列教程)
- CanIUse:animation-timeline(数据截至 2026-08)
- Safari 26 release notes:scroll-driven animations 落地(2025-09)
demo已经整理好,gzh程序员蜡笔熊回复「滚动动画」即可领取。
预告:卡片进度条都跟手了,但如果想把滚动进度写进一个 CSS 变量去驱动别的属性时,动画直接罢工,变量没有类型,插值器不认。@property 一行注册,自定义属性从「字符串」变「数值」,连颜色都能按帧插。
欢迎大家评论和提出意见。