滚动动效过去常从 scroll listener 开始:读取 scrollY,计算百分比,写 transform,再希望 throttle 能消除抖动。问题不只是事件频率;现代浏览器可以异步滚动,而主线程脚本的采样与滚动并不天然同步。CSS Scroll-driven Animations 把动画连接到滚动或元素可见进度的 timeline,使浏览器有机会在不为每次采样运行作者脚本的情况下更新效果。

这项能力仍不能当作无条件生产基线。MDN 在 2026 年 7 月仍将 animation-timeline 标为 Limited availability,CSSWG 的 Scroll-driven Animations Level 1 也是持续演进的 Editor’s Draft。本文因此把它作为渐进增强:完整 HTML 与操作先成立,支持的浏览器得到连续效果,不支持或 Reduced Motion 时保留静态内容。

Scroll progress 与 View progress 不同

Scroll progress timeline 追踪某个 scroll container 在一个轴上的滚动范围:起点对应 0%,末端对应 100%。它适合整篇阅读进度、横向画廊位置或容器背景变化。View progress timeline 追踪一个 subject 穿过最近 scrollport 的相对位置,适合卡片进入、覆盖和离开视口时的视觉变化。MDN 的 timeline 指南 对两者、匿名函数与命名时间线都有完整区分。

若容器没有 overflow,起点和终点重合,规范将 scroll timeline 视为 inactive。若 subject 的最近滚动祖先不是你以为的页面根节点,view() 也会跟随错误容器。调试第一步不是改 keyframes,而是确认真正的 scroller、轴、scrollport 与可滚动范围。

CSSWG 还明确区分 scroll-driven 与 scroll-triggered:前者的动画进度持续绑定滚动位置,向上滚会反向;后者只在越过边界时触发一个普通时间动画。卡片只需出现一次时,IntersectionObserver 加状态 class 可能更符合语义;阅读进度条才真正需要连续 timeline。

根页面阅读进度

一个无需业务脚本的进度条可以使用匿名根滚动时间线:

.reading-progress {
  position: fixed;
  inset: 0 0 auto;
  block-size: 3px;
  transform: scaleX(0);
  transform-origin: left;
  background: var(--accent);
}

@supports (animation-timeline: scroll()) {
  .reading-progress {
    animation: reading-progress 1ms linear both;
    animation-timeline: scroll(root block);
  }
}

@keyframes reading-progress {
  to {
    transform: scaleX(1);
  }
}

1ms 不决定滚动动画真实长度,timeline 用滚动范围提供进度;MDN 指出非零 duration 仍有跨浏览器兼容价值。更容易踩坑的是声明顺序:animation shorthand 会把之前的 animation-timeline 重置为 auto,所以 timeline 必须写在 shorthand 之后。animation-timeline 参考 对这个 reset-only 行为有明确说明。

不支持 animation-timeline 时,进度条保持 scaleX(0)。如果产品认为“没有进度条”会损害任务,就不应把它只做成增强层;可以提供静态章节目录或稳定 JavaScript 版本。本文示例把它定义为辅助反馈,所以基础阅读不依赖该条线。

元素进入视口的 View timeline

卡片出现效果应先保证默认状态完全可见,再只在支持块里设置动画。这样 CSS 解析失败、API 不支持或脚本关闭都不会留下透明内容:

.story-card {
  opacity: 1;
  transform: none;
}

@supports (animation-timeline: view()) {
  .story-card {
    animation: reveal-card 1ms linear both;
    animation-timeline: view(block);
    animation-range: entry 10% cover 35%;
  }
}

@keyframes reveal-card {
  from {
    opacity: 0.35;
    transform: translateY(1rem);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

entrycover 等 timeline range names 让起止点围绕 subject 与 scrollport 的几何关系表达,而不是手算窗口像素。范围语法与各浏览器实现仍需单独核验;若目标环境只支持基础 view(),可以先使用完整 0%–100% timeline,或把 range 声明放进更具体的 @supports

Range 不是传统 IntersectionObserver threshold。entry 10%cover 35% 描述的是具名几何阶段上的进度位置,subject 高度、scrollport 与 inset 都会改变实际像素。设计稿若只写“滚到 300px 开始”,在响应式页面和文字放大后很快失效;更稳妥的是说明“卡片进入可见区后开始,在主要内容完全可读前结束”,再用 range 表达。

轴应优先用 blockinline 等逻辑方向,除非效果确实绑定物理 x/y。竖排文字、RTL、横向 scroller 和嵌套 writing mode 都可能让“向下”等假设失效。动画方向反转时,信息层级仍应一致,不能让某种书写方向里的内容长期停在半透明状态。

CSS 时间线、静态内容与可选 IntersectionObserver 回退的增强层级

图 1:所有路径从可读 HTML 开始,支持条件只增加视觉,不把内容可见性押在动画 API 上;本站原创 1400×800 程序化 SVG。

匿名时间线与命名时间线

scroll(root block)view(block) 简洁,适合动画目标自己能找到正确 scroller 或 subject 的情况。命名时间线通过 scroll-timeline-nameview-timeline-name 暴露一个 <dashed-ident>,再由目标的 animation-timeline 引用,适合同一进度驱动另一个元素,或多个后代共享容器进度。

命名引入作用域问题。嵌套组件可能复用相同名称,目标也可能不在时间线可见作用域内。名称应属于组件合同,不要全站都叫 --timeline;需要跨兄弟或更大范围共享时,再评估 timeline-scope 的目标浏览器状态。若一个视觉只作用于元素自身,匿名 view() 通常更容易封装。

多动画列表还要对齐逗号值。animation-nameanimation-timeline 和 range 的列表按顺序配对,数量不一致会重复或忽略值。调试时先在 computed style 里确认每个 animation 实际绑定的 timeline,不要只看 CSS 源码。

渐进增强不等于复制两套引擎

对于离散“进入后加 class”,稳定的 Intersection Observer API 是合理回退:它异步报告阈值越界,不需要作者在每个 scroll event 里查询所有 rect。但 Observer 不能提供逐像素连续进度;强行用几十个 thresholds 模拟 timeline,会重新引入主线程工作与两套不一致的视觉逻辑。

export function initRevealFallback(root: ParentNode) {
  if (CSS.supports('animation-timeline', 'view()')) return () => {};

  const cards = [...root.querySelectorAll<HTMLElement>('[data-reveal]')];
  const observer = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          (entry.target as HTMLElement).dataset.visible = 'true';
          observer.unobserve(entry.target);
        }
      }
    },
    { threshold: 0.2 },
  );

  cards.forEach((card) => observer.observe(card));
  return () => observer.disconnect();
}

回退只提供一次离散 reveal,并返回 cleanup。默认 CSS 仍显示内容;只有脚本成功时才可以在初始化瞬间添加 enhancement class,然后等待可见。若 initialization 失败,不能让整页保持 opacity 0。更简单的产品可以完全不提供 JS fallback,让不支持者直接看到静态卡片。

服务端渲染与无 JS 是最容易验证的底线:直接查看输出 HTML,标题、段落、链接和图片应全部存在;禁用样式后阅读顺序仍正确;删除增强 class 后内容仍可见。只有装饰层可以依赖时间线,章节选择、表单提交或“加载更多”不能只在滚到某个像素后发生。

若滚动用于驱动 Canvas 或数据可视化,CSS timeline 未必能直接控制绘制参数。可以通过 WAAPI 的 ScrollTimeline 接口研究命令式连接,但该接口同样属于有限可用范围;生产前要逐浏览器核验。不要读取 CSS 动画 currentTime 再每帧转写 Canvas,这会把声明式优势绕回主线程同步桥。

性能优势有条件

规范设计允许用户代理在异步滚动架构下不运行作者脚本进行采样,但没有要求所有效果都离开主线程。CSSWG 文本 写的是“允许但不要求”异步采样;Chrome 官方指南 展示了可离开主线程的优势,也不能据此推断任意属性都免费。

若 keyframes 改变 width、filter、大面积 mask 或复杂背景,每个采样点仍可能触发布局或绘制。优先使用有限范围的 transform 与 opacity,再用 Performance trace 确认目标浏览器实际路径。大量 subject 各自拥有时间线、超长页面上持续采样离屏效果、嵌套 sticky 与 clip 也要单独测量。

Scroll-driven 动画与滚动捕获不是同一件事。不要为了让动画“更准”而劫持 wheel、强制 smooth scroll 或改写用户滚动位置;时间线应该跟随浏览器滚动,包括键盘 PageDown、滚动条拖动、触控和辅助技术操作。

Reduced Motion 与内容安全

持续视差、旋转、缩放和景深可能让 Reduced Motion 用户不适,应移除 timeline 并恢复静态终态:

@media (prefers-reduced-motion: reduce) {
  .story-card,
  .reading-progress {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

进度反馈若对导航很重要,可以保留无运动的章节状态或 <progress> 语义,而不是只隐藏视觉线。动画不能改变 DOM 顺序、把焦点元素移动到看似不同的位置,也不能让内容只在精确滚动姿势下可读。卡片 hover 操作要有 focus/tap 等价路径。

测试与失败模式

测试根页面与嵌套 scroller、block 与 inline 轴、内容不足以滚动、Dynamic Type 或浏览器缩放导致行高变化、sticky、overscroll、反向滚动、跳转 hash、前进后退恢复。至少覆盖一个支持完整 range、一个只支持部分语法和一个不支持 timeline 的浏览器,再测试无 JS 与 Reduced Motion。

常见失败包括:把 view timeline 当 scroll timeline;shorthand 重置 timeline;基础内容默认透明;没有 overflow 导致 inactive;命名时间线作用域错误;用 scroll listener 再复制一套连续引擎;动画 layout 属性却宣称 compositor-only;不清理 Observer;只用触控板测试;Reduced Motion 后仍有 sticky 视差。

Scroll-driven Animations 的正确价值,是把“滚动进度”提升为浏览器理解的时间模型,而不是让每个页面都加视差。先判断效果需要连续进度还是一次触发,再用 @supports 包住增强、保留静态终态,并在真实浏览器验证属性成本,有限可用的新能力才能安全进入生产。

Sources