几年前第一次用 IntersectionObserver 做懒加载时就在琢磨:滚动跟动画非得让 JS 牵线吗?浏览器明明知道滚动位置,也知道元素何时进入视口,却偏要把信息扔给 JS 去算,算完再改样式。这中间多出的每一步都是掉帧隐患。尤其当页面堆了十几个入场动画加视差效果,哪怕你用 requestAnimationFrame 压着,主线程上的 layout thrashing 也够喝一壶。

CSS scroll-driven animations 把“滚动位置决定动画进度”这条线,直接从 JS 手里抽走,塞进浏览器的合成器线程里走。Chrome 115 后稳定可用,polyfill 方案也成熟了,Vue 3 的 useTemplateRef 正好能帮我们做优雅降级。下面从原理讲到实战,不绕弯子。

滚动驱动?浏览器自己算进度

传统做法是监听 scroll 事件,算 scrollTop,再换算成百分比控制 opacity 或 transform。桌面端还能忍,换到移动端尤其是嵌套滚动容器,性能账单会变得很难看。每次滚动触发几十次样式重计算,其中大部分你根本不关心。

CSS scroll-driven animations 改写了这个逻辑。它把动画进度直接绑定到滚动容器的位置或某个元素在视口中的可见程度,交给浏览器内部的“动画工作线”调度。不需要监听 scroll,不占用主线程 JS 执行时间;动画进度与滚动天然同步,不存在事件队列延迟。支持两种时间线——(绑定滚动容器位置)和 (绑定元素自身可见比例)。

拿 view-timeline 举例。一个 class="fade-in" 的组件,想让它从进入视口到完全可见的过程中,透明度从 0 走到 1,同时向上位移 30px 归零。以往得写 IntersectionObserver 实例配合 CSS 变量或类名切换。现在三行 CSS:

.fade-in {
  view-timeline-name: --subjectReveal;
  animation: reveal both;
}

@keyframes reveal {
  from {
    opacity: 0;
    translate: 0 30px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

浏览器会在元素进入视口时自动启动动画,滚动越快动画推进越快。没 JS,没事件,没额外计算。注意 view-timeline-name 定义了自定义时间线,animation-timeline 默认取当前元素的 view-timeline,所以显式绑定可以省略。

如果你想要基于整个页面滚动位置来驱动某个元素动画,就用 scroll-timeline。比如一个固定在顶部的进度条:

@scroll-timeline page-scroll {
  source: auto;
}

.progress-bar {
  animation-timeline: page-scroll;
  animation: grow linear;
}

@keyframes grow {
  from { width: 0%; }
  to { width: 100%; }
}

以前是我们“告诉浏览器滚动到哪里了”,现在是浏览器“告诉我们动画该走到哪了”。让浏览器干它最擅长的事,别抢合成器线程的活。

Vue 3 useTemplateRef DOM reference

Vue 里拿 DOM 引用:从 ref 到 useTemplateRef

上一章把入场动画交给浏览器自己算,省了 IntersectionObserver 回调和节流。但把思路落地到 Vue 组件,得解决一个“日常”小麻烦:到底要把哪个节点交给 CSS timeline,或者丢给观察器。

以前大伙儿爱写 ref="myDiv",回头在 onMountedthis.$refs.myDiv。组件一多,这种字符串标签名就成了隐形地雷——拼写错一个字母,或延迟取值时 ref 还没挂上,TypeScript 只给一个宽宽的 HTMLElement,想精准类型得手动断言。这坑我踩过好几次。拼写对不上、取值时机不对、SSR 下直接 undefined,每一个都够蹲那儿 debug 小十分钟。

useTemplateRef 用法很直白。template 里写 ref="fadeIn",脚本用 useTemplateRef('fadeIn') 拿到对应的 HTMLElement | undefined。它是 Vue 3.5+ 提供的 Composition API,返回值本身就是响应式引用,不需要再套一层 ref()。来看代码:

import { useTemplateRef, onMounted } from 'vue'

export default {
  setup() {
    const fadeInRef = useTemplateRef('fadeIn')
    onMounted(() => {
      if (fadeInRef.value) {
        fadeInRef.value.style.viewTimelineName = '--subjectReveal'
      }
    })
    return { fadeInRef }
  }
}

配合 <template><div ref="fadeIn" class="fade-in"></div>,类型推导直接把引用到底存不存在讲得清清楚楚。更重要的是,它跟滚动驱动那套“让浏览器自己跑 keyframes”非常合拍。你只需要把正确节点暴露出去,剩下的交给 CSS。

注意:useTemplateRef 在 Vue 3.5+ 才稳定,如果你还在用 3.4 及以下,可以用 ref(null) + onMounted 手动赋值,类型断言自己写。升级到 3.5 后改起来成本很低。
component entrance animation scroll view timeline

卡片入场:从屏幕底部滑上来

好,工具到位了,直接写最常见的场景。卡片从屏幕底部滑上来,透明度从 0 变 1,离开视口时反向消失。效果很“苹果官网”,但背后全是 CSS 自己算的。

先定义 keyframes。注意这里不需要 animation 属性,只写 @keyframes 规则:

@keyframes slide-up {
  from {
    transform: translateY(80px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

关键在 CSS 类上。不能写死的 animation-duration,因为每个卡片滚入速度不一样。得用 view-timeline 把动画进度绑定到元素自身的可视区域百分比:

.fade-in-up {
  animation: slide-up linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

animation-range: entry 0% entry 100% 意思是:动画从元素刚进入视口(entry 0%)开始播放,到完全进入(entry 100%)结束。离开时自动反向,因为浏览器知道 timeline 往回走了。

坑来了。animation-timeline: view() 在 Chrome 115+ 默认就是 view(block),但如果卡片是横向滚动,得改成 view(inline)。我吃过一次亏,列表横向轮播死活不出动画,查了半天发现方向写错。

现在把 Vue 组件串起来。用 useTemplateRef 拿到元素,动态绑定 view-timeline-name:

<script setup>
import { useTemplateRef, onMounted } from 'vue'

const cardRef = useTemplateRef('card')

onMounted(() => {
  if (cardRef.value) {
    cardRef.value.style.viewTimelineName = '--card-timeline'
  }
})
</script>

<template>
  <div ref="card" class="card">
    <h3>标题内容</h3>
    <p>描述文字……</p>
  </div>
</template>

<style scoped>
.card {
  view-timeline-name: --card-timeline;
  view-timeline-axis: block;
  animation: slide-up linear both;
  animation-timeline: --card-timeline;
  animation-range: entry 0% entry 100%;
}
</style>

等等,这样写会发现 timeline 名称在 CSS 和 JS 里重复了。其实更干净的做法是只在 CSS 声明 view-timeline-name,JS 动态改 animation-timeline。但大多数场景直接挂类就够了。useTemplateRef 主要解决那种“组件内多个元素、需要精确控制哪个节点被观察”的混乱局面。

效果很实在:卡片滚动到视口底部边缘瞬间开始往上滑、变清晰。继续滚,它停在原位。往回滚,它原路消失。整个过程零 JS 监听滚动。

唯一要留意:animation-range 的单位是百分比或 px,不支持 calc。如果想让动画在元素进入 30% 后才触发(比如错落入场),可以用 entry 30% entry 100%。但别写负数,之前试过 entry -10%,Chrome 直接忽略整条规则。

几个卡片同时存在时,每个卡片独立计算自己的 view-timeline,互不干扰。这就够了。

视差效果:背景层与前景层不同速

把两层叠在一起,背景慢慢露头、前景飞快滑入。这味儿一出来页面就像有了景深。靠 scroll-driven animations 就能办到,不必算偏移、不必绑滚动事件。

先把时间线拉起来

给容器挂个 view-timeline-name,再给子层分别设 animation-range。背景用 entry 10% entry 80%,前景用 entry 0% entry 50%,起止点错开,速率感就出来了。Chrome 115+ 对 container-type: scroll 的支持很稳,Firefox 也跟上了。

.parallax-container {
  container-type: scroll;
  view-timeline-name: --parallax;
}
.bg-layer {
  animation: parallax linear both;
  animation-timeline: --parallax;
  animation-range: entry 10% entry 80%;
}
.fg-layer {
  animation: parallax-fast linear both;
  animation-timeline: --parallax;
  animation-range: entry 0% entry 50%;
}

Vue 这边怎么把线牵齐

同一堆元素里,背景和前景要各走各的动画,就得拿到各自的引用。以前 ref 同名会互相覆盖,现在用 useTemplateRef 配 v-for 的 key 能把每项分清,再把不同的 view-timeline-name 写到各自节点上。别在 timeline 名称里塞 calc;想延迟触发,就把 entry 起点往后挪,比如 entry 20% entry 100%

animation-range 支持百分比和 px;不支持 calc,也不允许负值。同一父级可定义多个 view-timeline-name,分层更清晰。

这么做最大的甜头是性能:动画交给合成线程,主线程不挨滚动事件的刀。偶尔还要照顾旧版浏览器,就回退到 IntersectionObserver + CSS transform,顺滑度虽差点,但也不至于卡住。

兼容性检测与降级方案

Chrome 115+ 跑得欢,Edge 也跟上了,但 Safari 和 Firefox 还在实验室里磨蹭。这不是秘密,@supports 就能解决。

写个检测,把老浏览器的路也铺好。

@supports (animation-timeline: scroll()) {
  .parallax-container {
    container-type: scroll;
    view-timeline-name: --parallax;
  }
  .bg-layer {
    animation: parallax linear both;
    animation-timeline: --parallax;
    animation-range: entry 10% entry 80%;
  }
}

不支持的浏览器怎么办?回退方案用 IntersectionObserver。Vue 里封装一个 composable,保持组件干净。

import { ref, onMounted, onUnmounted } from 'vue'

export function useScrollParallaxFallback(elRef) {
  const progress = ref(0)
  let observer = null

  onMounted(() => {
    if (!elRef.value) return
    observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          const rect = entry.boundingClientRect
          const windowHeight = window.innerHeight
          progress.value = Math.min(1, Math.max(0, 
            (windowHeight - rect.top) / (windowHeight + rect.height)
          ))
        }
      },
      { threshold: Array.from({ length: 100 }, (_, i) => i / 100) }
    )
    observer.observe(elRef.value)
  })

  onUnmounted(() => observer?.disconnect())

  return { progress }
}

然后在组件里用 useTemplateRef 拿引用,传给这个 composable。不支持的浏览器里,@supports 块不生效,CSS 动画走不了,但 JS 降级会把 progress 值丢给模板,用 :style 绑定 transform: translateY()。顺滑度差一点,但功能不掉。

@supports (animation-timeline: scroll()) 检测新特性,不能写错,scroll() 是函数,不是关键字。IntersectionObserver 的 threshold 数组别太大,100 个分段够用,再多性能就崩了。Vue 3.5+ 的 useTemplateRef 不会覆盖同名 ref,省心。

说到底,新东西好用但别赌用户都用 Chrome。降级方案写好了,心里才踏实。


从最初用 IntersectionObserver 做懒加载,到现在用 CSS scroll-driven animations 直接让浏览器接管动画进度,中间隔了好几个版本。工具在变,但思路没变:让浏览器做它最擅长的事,把主线程留给更重要的交互。写完最后一个 @supports 分支,合上编辑器那刻,感觉顺滑这件事终于不用靠运气了。