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 代码里往往充满了位置计算和魔法数字,半年后再看很难一眼判断动画到底想表达什么。更糟的是这种逻辑常常散落在组件的 mounteduseEffect、工具函数里,改一个动画范围要翻好几个文件。

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 指元素从开始进入到完全离开视口的整个覆盖过程;还有 exitcontain 等。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-namescroll-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 接口 ScrollTimelineViewTimeline,可以直接传给 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 时间线,并不等于它一定跑在合成线程、一定不掉帧。滚动驱动动画能不能上合成线程,取决于你动的是什么属性。改 opacitytransform 这类不触发布局和绘制的属性,浏览器有机会把整段动画放到合成层,滚动时几乎零成本;可一旦你在关键帧里改 widthtopmargin 这种会引发 layout 的属性,动画就得回到主线程逐帧重排,view() 省下的那点开销瞬间又还回去了,甚至比手写 IntersectionObserver 加 class 还糟。所以关键帧里写什么属性,比“用不用 CSS 时间线”更影响性能。我给自己定的规矩是:滚动动画的关键帧里,非 opacity/transform 不写,实在要动尺寸就换成 scale 来近似。

我现在会先问:这个滚动动画是否真的帮助用户理解内容?如果只是为了让页面“看起来高级”,我宁愿少写。

CSS view() 的价值在于减少滚动监听和手动计算。

它不是为了替代所有 JavaScript 动画,而是让“元素进入视口时播放动画”这类需求变得更自然。能用 CSS 表达的滚动关系,就不要急着上复杂脚本。