CSS view() 滚动动画:我想少写的是那堆 scroll 计算
做滚动动画时,很多前端都会先想到 JavaScript。
监听 scroll,读取元素位置,计算进入视口的比例,再把结果写回样式。这个方法能用,但代码一多就很难维护。
CSS view() 代表的是另一种方向:让浏览器直接根据元素在视口中的位置推进动画。
我以前做运营页和数据看板时写过不少滚动动画。第一版通常很快,监听 scroll、算元素位置、改透明度和位移。但页面一长、元素一多,代码就开始变得难维护:节流、极端情况、回收、移动端性能,每个都要补。
手头一个年终总结类 H5 就是这样,设计稿里有十几屏,每屏都有元素分批淡入上滑。我当时图省事,把所有需要动画的节点收集成一个数组,挂一个 scroll 监听,循环算每个元素的进度。上线第一天就被测试同学截图:在某些中低端安卓机上滚动会顿,淡入的元素一卡一卡的。我去 Performance 面板里录了一段,scroll 回调每秒被打了上百次,每次都在循环里调 getBoundingClientRect,强制同步布局(layout thrashing)直接把帧率拖到二十多。用 IntersectionObserver 重写了一版,性能好了很多,但那一版代码量又涨了一截,回调里还是要自己管进度、管已经播放过的元素、管来回滚动时要不要重播。
所以我关注 view(),不是因为它能做多花的动画,而是因为它把“元素进入视口时推进动画”这件事交还给了 CSS。这类动画本质上是个声明式的需求——“这张卡片进视口就淡入”——却被我们用一堆命令式的计算实现了,中间的阻抗一直都在。
传统滚动动画的问题
常见写法大概是这样:
1window.addEventListener("scroll", () => { 2 const rect = element.getBoundingClientRect(); 3 const progress = 1 - rect.top / window.innerHeight; 4 element.style.opacity = Math.max(0, Math.min(1, progress)); 5});
问题在于:
- 滚动事件触发频繁
- 需要自己算进度
- 要处理节流和性能
- 多个元素动画时逻辑会膨胀
scroll 事件默认不在合成线程跑,主线程一忙,回调就延迟,动画就掉帧。getBoundingClientRect 又是个会触发回流的读操作,如果你在回调里先读位置、再写 style、下一个元素又读位置,浏览器就被迫反复重排,这就是经典的 layout thrashing。我后来养成习惯,要么把读和写分两批做,要么干脆别在 scroll 回调里读布局——但这些都是在给一个本不该由 JS 承担的活儿打补丁。
升级到 IntersectionObserver 能解决“频繁触发”和“强制回流”,因为它是浏览器在合适时机回调你,不用自己抓位置:
1const io = new IntersectionObserver( 2 (entries) => { 3 for (const entry of entries) { 4 if (entry.isIntersecting) { 5 entry.target.classList.add("is-visible"); 6 io.unobserve(entry.target); // 只播一次,播完取消观察 7 } 8 } 9 }, 10 { threshold: 0.2 } 11); 12 13document.querySelectorAll(".card").forEach((el) => io.observe(el));
这版干净不少。但它本质上是个开关:进视口加个 class,靠 CSS transition 播一段固定时长的动画。它没法表达“动画进度跟着滚动位置走”——比如视差、阅读进度条这种需要连续映射的效果,IntersectionObserver 就力不从心,又得退回 scroll 监听去算比例。
如果只是让动画跟着滚动走,这些逻辑不一定都该交给 JavaScript。
还有一个问题是可读性。滚动动画的业务意图本来很简单:这张卡片进入视口时淡入。但 JS 代码里往往充满了位置计算和魔法数字,半年后再看很难一眼判断动画到底想表达什么。更糟的是这种逻辑常常散落在组件的 mounted、useEffect、工具函数里,改一个动画范围要翻好几个文件。
view() 的核心思路
view() 可以把元素进入视口的过程当成动画时间线。
也就是说,动画不再按固定秒数播放,而是根据元素和视口的相对位置播放。
示意代码如下:
1.card { 2 animation: fade-in linear both; 3 animation-timeline: view(); 4 animation-range: entry 0% cover 40%; 5} 6 7@keyframes fade-in { 8 from { 9 opacity: 0; 10 transform: translateY(24px); 11 } 12 13 to { 14 opacity: 1; 15 transform: translateY(0); 16 } 17}
元素进入视口时,动画开始推进;进入到指定范围后,动画完成。
要确认浏览器认不认 scroll() / view() 这种 timeline,在控制台敲一行检测就行,返回布尔:
1CSS.supports('animation-timeline', 'scroll()'); // 支持的浏览器 → true 2CSS.supports('animation-timeline', 'view()'); // 同理
第一次上手我建议跑个能直接滚动观察的最小 demo。给一个比视口高很多的容器,里头放个进度条,用 scroll() 时间线把它的宽度绑到滚动进度上:
1.progress { 2 position: fixed; 3 top: 0; left: 0; height: 4px; 4 background: crimson; 5 transform-origin: left; 6 animation: grow linear both; 7 animation-timeline: scroll(root block); /* 跟根滚动容器的纵向滚动绑定 */ 8} 9@keyframes grow { 10 from { transform: scaleX(0); } 11 to { transform: scaleX(1); } 12}
把页面滚到很长,你会看到顶部那条线随滚动从 0 拉满到 100%——这里没写一行 scroll 监听,进度完全由滚动位置驱动。scroll() 绑的是滚动容器自身的进度,view() 绑的是某个元素进出视口的进度,这是两者的分工。想细看推进过程,较新版本的 Chrome DevTools 在 Animations 面板能把滚动驱动的时间线显示出来,拖动滚动条时面板里的进度会跟着走。
这段 CSS 的好处是,动画意图直接写在样式里。animation-timeline: view() 表示时间线来自元素和视口的关系,animation-range 表示在哪个阶段推进。它比一段滚动监听更接近设计语言。
这里有几个容易理解错的点:
animation-range 里的关键词是有讲究的。entry 指元素从底部边缘开始进入视口、到完全进入这段过程;cover 指元素从开始进入到完全离开视口的整个覆盖过程;还有 exit、contain 等。entry 0% cover 40% 的意思是动画从“刚开始进入视口”起步,到“覆盖到 40%”时结束。我最初凭感觉写了 cover 0% cover 100%,结果元素还在屏幕外动画就开始推进了,因为 cover 的 0% 是元素顶边刚碰到视口底边的那一刻,比我想要的“看得见才动”要早。调范围这件事最好开着 DevTools 实时看,光想是想不准的。
另外一定要写 animation-fill-mode: both(上面简写里那个 both)。view() 时间线在动画范围之外是不推进的,如果不写 both,元素在范围之前可能直接显示成 to 状态或者初始状态闪一下,淡入效果就废了。linear 也别省,滚动驱动的动画用线性缓动跟手感最自然,套 ease 反而会让“滚动多少、动多少”的对应关系变得别扭。
如果想做视差,可以把时间线显式命名出来,让多个元素共享同一条滚动时间线,比纯靠各自的 view() 更好对齐:
1.parallax-section { 2 view-timeline-name: --hero; 3 view-timeline-axis: block; 4} 5 6.parallax-bg { 7 animation: drift linear both; 8 animation-timeline: --hero; 9 animation-range: cover 0% cover 100%; 10} 11 12@keyframes drift { 13 from { transform: translateY(-8%); } 14 to { transform: translateY(8%); } 15}
view-timeline-name 给某个元素挂一条具名时间线,其它元素用 animation-timeline: --hero 引用它,背景图就能跟着这个 section 的滚动连续位移,做出视差。这种连续映射正是 IntersectionObserver 做不了、过去只能手算 scroll 比例的场景。
具名时间线的作用域坑
用 view-timeline-name 或 scroll-timeline-name 起一条具名时间线时,有个很容易撞上的限制:默认情况下,只有那个声明时间线的元素的后代才能用 animation-timeline 引用到它。也就是说,时间线是往下传的,不往上、也不往旁边的兄弟节点传。
我第一次做“阅读进度条跟着文章滚动”就栽在这里。进度条我为了固定在顶部,放在了 <body> 顶层,而 scroll-timeline-name 声明在下面的 .article 容器上。结果进度条死活不动——它是 .article 的兄弟,根本不在作用域内,引用了个不存在的时间线,CSS 直接当无效声明丢掉,既不报错也没效果,排查时特别摸不着头脑。
解决办法是 timeline-scope,把时间线的作用域往上提到一个共同祖先,让不在后代链上的元素也能引用:
1body { 2 timeline-scope: --article-progress; /* 把这条时间线的作用域提到 body */ 3} 4 5.article { 6 scroll-timeline-name: --article-progress; 7 scroll-timeline-axis: block; 8} 9 10.reading-progress { 11 animation: grow linear both; 12 animation-timeline: --article-progress; /* 现在能引用到了 */ 13}
timeline-scope 声明在哪个元素上,那条具名时间线就在这个元素的整棵子树里可见。所以只要把它挂在进度条和文章容器共同的祖先上,两边就能对上了。这个属性文档里着墨不多,但一旦你的动画元素和滚动容器不是父子关系,基本绕不开它。
顺带提一个和它相关的判断:一条滚动时间线可以被多个元素引用,但一个元素上的 animation-timeline 只能绑一条时间线。想让同一个元素既受滚动进度影响、又受另一段视口关系影响,是做不到的,得拆成嵌套的两层元素各绑一条。我做视差叠加效果时试过硬塞,才发现这条限制。
适合哪些效果
view() 很适合:
- 内容淡入
- 卡片上滑
- 图片视差
- 阅读进度相关动效
- 分段页面滚动动画
这些效果都和“元素是否进入视口”直接相关。
我会优先把它用在体验增强场景,比如文章卡片、统计模块、时间线节点、图片说明。它们即使没有动画,内容也应该完整可见。
不太适合的场景是强业务状态,比如“必须滚动到这里才加载关键数据”“动画完成后才显示按钮”。这类逻辑不应该依赖 CSS 动画驱动,业务状态还是要交给明确的程序逻辑。
使用时要注意兼容
滚动驱动动画属于较新的 CSS 能力,实际项目中要检查目标浏览器支持情况。
比较稳妥的做法是:
- 关键内容不能依赖动画才可见
- 不支持时保持静态展示
- 动画只作为体验增强
- 控制动画数量,避免页面过度运动
可以用 @supports 做渐进增强:
1.card { 2 opacity: 1; 3} 4 5@supports (animation-timeline: view()) { 6 .card { 7 animation: fade-in linear both; 8 animation-timeline: view(); 9 animation-range: entry 0% cover 40%; 10 } 11}
这样不支持的浏览器看到的是静态内容,支持的浏览器得到增强效果。关键内容不应该因为动画能力缺失而不可见。
补一句兼容性现状,我不背具体版本号:较新版本的 Chrome 已经支持滚动驱动动画,Safari 与 Firefox 还相对落后,准确的支持矩阵去 caniuse / MDN 查 "scroll-driven animations" 最稳妥。好在这套能力有个友好的特性——不支持的浏览器遇到 animation-timeline: view() 只是把这条声明当成无法识别的属性丢弃,动画不生效,但不会报错、不会白屏,元素还按你写的初始样式正常显示。所以即便不写 @supports,降级也是"无动画的静态展示"而非崩坏;@supports 的价值是让你能主动把降级态写得更可控(比如不支持时给个轻量的 transition 打个底)。
动效要尊重用户偏好
滚动动画还有一个容易被忽略的点:不是所有用户都喜欢动效。
如果系统开启了减少动态效果,页面应该收敛动画:
1@media (prefers-reduced-motion: reduce) { 2 .card { 3 animation: none; 4 transform: none; 5 } 6}
这不是形式。大量滚动动效会让一部分用户不舒服,也会让页面显得浮躁。尤其是工具型、后台型页面,动效应该帮助理解层级,而不是抢注意力。
值得一提的是,滚动驱动动画不止有 CSS 一种写法,还有配套的 JS 接口 ScrollTimeline 和 ViewTimeline,可以直接传给 element.animate():
1const el = document.querySelector(".card"); 2el.animate( 3 [{ opacity: 0, transform: "translateY(24px)" }, { opacity: 1, transform: "none" }], 4 { timeline: new ViewTimeline({ subject: el }), rangeStart: "entry 0%", rangeEnd: "cover 40%" } 5);
我大多数时候还是用纯 CSS,因为声明式更好维护;但如果动画的范围、关键帧需要根据运行时数据算出来(比如进度条要按接口返回的章节数分段),JS 版本能拿到变量就方便多了。两套底层是同一套时间线机制,选哪个只是看这段动画的参数是不是编译期就定死的。
不要把它当成性能银弹
view() 能减少很多手写 scroll 监听,但不代表动画就一定便宜。
如果一个页面上几十个元素同时做复杂 transform、filter、阴影、模糊,浏览器一样有压力。动效仍然要控制数量和属性,尽量使用 opacity、transform 这类更适合动画的属性。
这里有个常被误解的点:把动画交给 CSS 时间线,并不等于它一定跑在合成线程、一定不掉帧。滚动驱动动画能不能上合成线程,取决于你动的是什么属性。改 opacity、transform 这类不触发布局和绘制的属性,浏览器有机会把整段动画放到合成层,滚动时几乎零成本;可一旦你在关键帧里改 width、top、margin 这种会引发 layout 的属性,动画就得回到主线程逐帧重排,view() 省下的那点开销瞬间又还回去了,甚至比手写 IntersectionObserver 加 class 还糟。所以关键帧里写什么属性,比“用不用 CSS 时间线”更影响性能。我给自己定的规矩是:滚动动画的关键帧里,非 opacity/transform 不写,实在要动尺寸就换成 scale 来近似。
我现在会先问:这个滚动动画是否真的帮助用户理解内容?如果只是为了让页面“看起来高级”,我宁愿少写。
CSS view() 的价值在于减少滚动监听和手动计算。
它不是为了替代所有 JavaScript 动画,而是让“元素进入视口时播放动画”这类需求变得更自然。能用 CSS 表达的滚动关系,就不要急着上复杂脚本。