CSS 滚动驱动动画:先换进度条
前端约 4 分钟阅读

CSS 滚动驱动动画:先换进度条

用 animation-timeline 的 view()/scroll() 替换一部分 IntersectionObserver。讲 range、reduced-motion、与 React 列表 remount 的边界,以及和预渲染叠用时的克制。

Chrome 把 Speculation Rules 做熟之后,下一波被低估的能力其实是 CSS 自己的时间轴:animation-timeline: view() 和 scroll()。列表进视口、章节进度条、封面图随滚动压暗,以前要么上 IntersectionObserver,要么上一堆库。现在浏览器在合成线程上就能推,主线程少挡一截。

我最近把一个内容站的“卡片渐显 + 目录高亮”从 JS 观察器迁到了 scroll-driven animations。体感不是魔法,是少了几处闪一下和 resize 后状态丢的坑。下面按能上线的顺序写。

先分清两种时间轴

scroll() 绑定滚动容器的滚动进度,适合进度条、视差背景。view() 绑定某个元素进入/离开视口的过程,适合卡片 fade-up、章节标题吸顶时的透明度。

css
/* 章节进度:跟着文档滚动走 */
.progress {
  animation: grow-x linear both;
  animation-timeline: scroll(root block);
}
@keyframes grow-x {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

/* 卡片:进视口时淡入上移 */
.card {
  animation: rise linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(16px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

animation-range 决定“哪一段视口行程对应完整动画”。写得太窄,用户一滑就闪完;写得太宽,动画拖沓。列表页我一般从 entry 0% 到 cover 30%–50% 试起。

和 React / Next 一起用时的边界

组件卸载再挂载,CSS 动画会重跑。App Router 里客户端导航如果整段列表 remount,用户会看到“又渐显一遍”。对策很土:

  • 列表尽量留在同一 layout 子树,别为了路由切换把整页卸掉。
  • 需要“只第一次出现才动”时,给看过的 id 打标记,改 class 关掉 animation,而不是指望浏览器记仇。
  • prefers-reduced-motion: reduce 必须给静态终态,不能只 animation: none 却把 opacity: 0 留在 from。
css
@media (prefers-reduced-motion: reduce) {
  .card {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

和 View Transitions、Speculation Rules 可以叠,但别叠成马戏:预渲染已经很快时,再给每次路由加 400ms 共享元素,只会显得迟钝。滚动驱动适合页内节奏;跨页交给 VT 或直接硬切。

图:列表卡片沿视口时间轴依次显现

性能:看起来省 JS,别又造回流

scroll-driven 的好处是时间轴由浏览器推进,不必每帧在 JS 里读 getBoundingClientRect。你仍可能自己把收益做没:

  • 动画属性优先 transform / opacity,少动 top、height、复杂 filter。
  • 大图 + blur 随滚动变化,低端 Android 上掉帧比 Observer 方案还难看。
  • 无限滚动里上千个 view() 动画元素,内存和图层会涨。虚拟列表只挂载可见区时,通常更稳。

DevTools 的 Animations 面板能看到 timeline 是否绑上。若元素有 overflow: hidden 的祖先裁切奇怪,view() 的可见范围会和你直觉不一致,这是我踩过最久的一次:设计师说“进屏才动”,实际是父级 clip 把 entry 算短了。

和旧方案怎么替换

场景 以前 现在可先试
进屏渐显 IntersectionObserver + class view() + animation-range
阅读进度 scroll 事件 + 宽度百分比 scroll(root)
目录当前章节 多个 observer 章节 view() 改 aria-current 仍要一点 JS
视差背景 手写 translate scroll() 驱动 transform

目录高亮我仍留了很小一段 JS:CSS 擅长画,不擅长维护“当前 id”这种应用状态。能少监听 scroll 就少监听,不必教条清零 JS。

特性检测别只看 CSS.supports('animation-timeline', 'view()')。真机 Safari / 旧 WebView 参差不齐,降级路径应是“直接显示终态”,而不是半截 opacity 0。

落地顺序(我自己的清单)

  1. 全局阅读进度条:改动面小,坏了也不影响可读性。
  2. 列表卡片 entry 动画:注意 reduced-motion 与虚拟列表。
  3. 再考虑封面压暗、章节装饰线等“气氛组”。
  4. 和 Instant Navigations / 预渲染一起开时,在真机弱网看一次:动画再漂亮,也抵不过壳先到、内容后到时的布局跳动。

官方文档和 demo 很容易让人写全站电影感。产品页可以浪一点;工具台账、后台表格就别给每行加 entry 了——用户要的是扫得快,不是每行谢幕。

小结

Scroll-driven animations 把一部分“滚动叙事”从 JS 观察器挪回 CSS,主线程更干净,代码也更短。它解决的是页内时间,不是路由架构。和 Speculation Rules、View Transitions 一样:先找一个高频、可降级的点替换,测 reduced-motion 和低端机,再考虑第二处。

我这边替换完最明显的变化不是“更炫”,而是 resize、返回列表、快速连滑时少了几处 class 时序 bug。够用就好。

相关文章