目录
3617 字
18 分钟
CSS 滚动驱动动画:时间轴换成滚动进度后,duration、fill 与 range 都要重算

一、它不报错,只是没按你想的跑#

先看一段很自然的写法:

.card {
opacity: 0;
animation: card-in 600ms ease both;
animation-timeline: view();
}

先声明动画,再把它挂到视口时间轴上。滚动到卡片进入视口时,它从透明淡入、向上归位,看起来一切正常。

后来有人整理属性顺序,或者在某处用媒体查询重写了 animation,代码变成了这样:

.card {
opacity: 0;
animation-timeline: view();
animation: card-in 600ms ease both;
}

滚动时什么也不会发生。页面加载后,卡片用 600ms 播完动画,停在终点。控制台没有任何提示,DevTools 里也只是一条普通的文档时间轴动画。

原因是 animation 简写会把 animation-timeline、animation-range-start、animation-range-end 一起重置回初始值。简写本身不包含这几个属性,却会清掉它们——声明顺序是语义的一部分。第一段能跑,只是因为 animation-timeline 恰好写在简写后面。

这类问题之所以难查,是因为我们容易把滚动驱动动画理解成「用 CSS 写 scroll 事件」。它不是。它做的是把动画的时间轴从单调时钟换成一条进度轴。时间轴一换,duration、delay、fill、iteration 这些词的含义都要重新翻译:

旧直觉进度时间轴下的实际含义
duration 决定动画快慢时间值被当作 auto,滚动距离决定快慢
delay 推迟开始没有「等待」这回事,用 animation-range 指定起点
动画播一遍就结束进度可逆,往回滚动画就倒放
fill 只影响结束后区间外的前、后两个阶段都由 fill 决定
JS 监听 scroll 更灵活CSS 版本不用挂事件处理器,但照样可能触发布局

后面几节就按这张表逐项拆开。

二、先把两种进度时间轴分清楚#

滚动驱动动画里的时间轴属于 progress-based timeline:它没有「现在几点」,只有「到哪了」。进度可以倒退,也可以因为用户拖动滚动条而跳变。

规范定义了两类:

时间轴进度由什么决定主体默认区间典型用途
scroll progress timeline滚动容器的滚动位置 / 可滚动距离滚动容器0% 到 100% 的滚动范围阅读进度条、视差、整页推进
view progress timeline元素相对滚动视口的位置被观察的元素cover入场、退场、元素途经视口时的变化

scroll() 回答的是「这个滚动容器滚到哪了」,view() 回答的是「这个元素在视口里走到哪了」。前者与元素自身的位置无关,后者与整个页面的滚动距离无关——这是选型时首先要分清的一件事。

三、最小可用:一条阅读进度条#

用 scroll(root block) 把文档滚动容器挂上去,进度条就会跟着页面走:

.reading-progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transform: scaleX(0);
transform-origin: 0 50%;
}
@supports (animation-timeline: scroll()) {
.reading-progress {
animation: reading-progress linear both;
animation-timeline: scroll(root block);
}
}
@keyframes reading-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}

这里有三个刻意的选择:

  1. 不写 duration。 在进度时间轴上,时间值不参与计算。写 2s 还是 200ms,结果都一样;进度条走多快,取决于页面有多长。
  2. 只改 transform。 scaleX() 可以由合成线程处理。如果改成 width: 0% 到 width: 100%,滚动每移动一像素都要重新布局。
  3. 基础样式先写成「没有动画也正确」的样子。 不支持 animation-timeline 的浏览器会忽略整个 @supports 块,进度条停在 scaleX(0),也就是隐藏。对阅读进度条来说,这比「一直显示满格」更接近正确。

还有一个边界:如果页面内容比视口还短,根本没有可滚动距离,scroll 时间轴是 inactive 的,进度条会停在 0%。这通常可以接受;如果要区分「没得滚」和「没开始读」,就在 JS 里读一次 scrollHeight,决定是否显示这条进度条。

四、命名时间轴与 timeline-scope:解决「看不见」#

匿名 scroll() 解析的是离元素最近的滚动容器。多数时候够用,但有两种情况会失效:

  • 你想跟踪的不是最近的滚动容器,而是页面上另一个横向滚动的画廊;
  • 被动画的元素根本不在那个滚动容器的子树里。

这时给滚动容器起名字:

.gallery {
overflow-x: auto;
scroll-timeline: --gallery inline;
}
.gallery__caption {
animation: caption-in linear both;
animation-timeline: --gallery;
animation-range: 0% 40%;
}

时间轴名字沿祖先链查找。--gallery 声明在滚动容器上,滚动容器自己和它的后代都能看到;如果标题在滚动容器外面(比如画廊上方的一条说明),就需要在共同祖先上把名字提升到更大的作用域:

.page {
timeline-scope: --gallery;
}

timeline-scope 只对 <dashed-ident> 形式的命名时间轴生效,对匿名 scroll() / view() 无效。它的作用不是创建时间轴,而是让一个子树上声明的名字在共同祖先处可见。

五、view() 的四个区间:cover、contain、entry、exit#

view() 把「元素经过视口」切成几个命名区间。理解它们的关键,是记住每个区间只是在描述两条边的相遇:

区间0% 的位置100% 的位置适合
cover元素前缘刚碰到视口后缘元素后缘刚离开视口前缘覆盖整个可见过程,view() 的默认值
entry元素前缘刚进入视口后缘元素完全进入视口(contain 起点)入场动画
exit元素开始离开视口(contain 终点)元素后缘完全离开视口前缘退场动画
contain元素完全进入视口元素仍然完整留在视口内元素比视口矮时的「停留」区间

cover 的跨度比 entry 大:元素还没真正进入视口,cover 的进度就已经在走了。这就是为什么给卡片写 view() 入场淡入时,往往要显式改成 entry——否则动画在元素露出边缘之前就播完了。

contain 有一个容易踩的陷阱:它要求「元素完整落在滚动视口内」这个状态真实存在。如果元素比滚动视口还高(长文段落、整屏 section),它永远不可能完整进入,contain 的起点会跑到终点后面,区间退化成反向甚至空区间,动画可能整段不跑。比视口矮的卡片适合 contain;比视口高的元素应该用 cover、entry、exit。

如果页面有固定头部,元素进入视口时会被遮住一截,可以用 view-timeline-inset 把计算区间用的视口缩进去:

.card {
view-timeline: --card block;
view-timeline-inset: 72px 0 0;
}

上边距缩进 72px 之后,「完全进入」的判定会落在固定头部下方,而不是被头部盖住的位置。

六、animation-range:把动画钉在哪一段#

view() 的默认区间是 cover,scroll() 的默认区间是整个滚动范围。多数时候你需要更精确的起止:

.card {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 15% cover 40%;
}

这段的意思是:从 entry 区间的 15% 开始,到 cover 区间的 40% 结束。两个端点可以落在不同的命名区间里,百分比始终相对于它自己那个命名区间计算——entry 50% 是「进入过程走了一半」,不是「页面滚了一半」。

几个常用组合:

/* 只在进入过程中淡入,完全进入时结束 */
animation-range: entry 0% entry 100%;
/* 进入一半时就开始,完全进入时已经结束 */
animation-range: entry 0% entry 60%;
/* 覆盖整个可见过程,滚出后才结束 */
animation-range: cover 0% cover 100%;
/* 进入后先停住,离开时再做退场 */
animation-range: contain 0% exit 100%;

animation-range-start 和 animation-range-end 是两个长属性,效果相同。百分比可以小于 0 或大于 100%,用来把区间延伸到命名区间之外;但多数场景不需要这么做,先把命名区间选对更重要。

七、duration、iteration、easing 在新轴上的分工#

把常用属性逐项翻译一遍:

属性在进度时间轴上还剩下什么实际建议
animation-duration时间值不参与计算,被当作 auto不要用它调快慢,调 animation-range 或滚动距离
animation-delay按时间计算的推迟没有意义用 animation-range-start 指定从哪一段开始
animation-iteration-count把进度区间切成若干遍一般不需要;要重复就配合 direction
animation-directionalternate、reverse 仍然有效往返效果可以直接用
animation-timing-function作用于「进度到进度的映射」steps() 可以做离散跳变;linear 通常最符合滚动直觉
animation-fill-mode决定区间前、区间后的状态多数场景用 both,下面单独说

linear 在滚动驱动动画里通常比 ease 更自然:用户滚动的速度本身已经在变化,再叠加一条缓动曲线,元素会显得忽快忽慢。需要「滚动到某一段时突然跳一格」的效果,steps(4) 比手写关键帧更直接。

八、区间外发生了什么:fill 与「回到起点」#

进度时间轴把动画拆成三个阶段:区间之前(before phase)、区间之内(active phase)、区间之后(after phase)。animation-fill-mode 决定前后两个阶段用什么值:

  • none(默认):区间外不应用动画值,元素回到基础样式;
  • forwards:区间之后保持 100% 的关键帧;
  • backwards:区间之前就应用 0% 的关键帧;
  • both:前后都保持。

这解释了一个常见的「bug」:卡片滚出视口后突然消失。不是动画坏了,而是 fill: none 让它在 after phase 回到了基础样式。入场动画通常写 both,离开视口后保持终点状态。

但 both 也有代价:如果基础样式本身就是隐藏态(比如 opacity: 0),而动画没有被创建(不支持、被覆盖、选择器写错),元素就会一直不可见。所以更稳的写法是基础样式写最终态,关键帧只提供起点:

.card {
opacity: 1;
transform: none;
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.card {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 10% entry 100%;
}
}
}
@keyframes card-in {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: none; }
}

不支持时,卡片就是正常可见的卡片;开启「减少动态效果」时,同样直接显示最终态。动画只是增强,不承担「让内容出现」的职责。

这里还有一个坑:想关掉动画时,不要写 animation-timeline: none。它只是把时间轴换回文档时间轴,动画本身还在,可能瞬间跳到终点。要关就写 animation: none。

九、性能:不占 scroll 监听,不等于免费#

滚动驱动动画的一个卖点是「不阻塞主线程」:滚动位置由合成器提供,只改 transform、opacity 这类属性的动画可以留在合成线程,不必每帧回到主线程跑 JS。

但这个前提比听起来窄:

  • 只对可合成属性成立。 改 width、height、top、left、margin 会触发布局,滚动每移动一点都要重新算,和用 JS 改样式没有本质区别。
  • 滚动容器本身要能走合成路径。 嵌套滚动、非合成的 overflow 容器可能让动画掉回主线程。
  • 动画数量会累积。 一屏几十张卡片同时挂 view() 时间轴,每个都要维护进度;能用一个父容器统一驱动的,不要拆成几十个。

它比 scroll 事件好的地方在于:监听的是滚动位置本身,键盘翻页、拖动滚动条、惯性滚动都不会漏;而只监听 wheel 的实现会漏掉其中的大部分。

十、渐进增强与可访问性#

滚动驱动动画的降级策略可以归纳成三条:

  1. 静态样式先正确。 先写没有动画时的最终布局与可见性,再用 @supports 叠加起点。内容不能依赖动画才出现。
  2. 用能力检测,不用版本号。 @supports (animation-timeline: view()) 比判断浏览器版本可靠;同一个浏览器的不同平台也可能不同。
  3. 尊重 prefers-reduced-motion。 视差、大位移和缩放是前庭不适的高发区。把动画整体放进 no-preference 的媒体查询里,而不是只把时长改成 0——时长在这里本来就不起作用。

当前的支持情况大致是:Chrome / Edge 从 115 起支持,Safari 在 26 中加入,Firefox 的默认支持要以你的目标版本为准。上线前用 @supports 实测一次,不要只看版本号。

十一、什么时候不该用它#

滚动驱动动画适合「视觉随滚动位置连续变化」的效果,不适合下面几类需求:

  • 要在滚动中改变布局。 比如根据滚动位置重排列表。这类动画即使写出来,也会因为布局成本而卡顿;先想清楚是不是非做不可。
  • 要做检测。 无限滚动、图片懒加载、曝光统计,应该用 IntersectionObserver。动画是表现层工具,不是可见性检测器。
  • 要做滚动劫持或分页。 需要锁定滚动、整屏切换时,scroll-snap 或显式的 JS 控制更合适;用动画去模拟只会让键盘和触控板用户困惑。
  • 需要兼容旧浏览器又没有降级设计。 如果动画承载了信息(比如进度、状态),不支持时必须有等价表达。

十二、小结#

  • 滚动驱动动画换的是时间轴,不是写法。 它把单调时钟换成可逆、可跳变的进度轴,duration、delay、fill 的含义都要重算。
  • animation 简写会重置 animation-timeline 和 animation-range-*。 顺序写反,动画会静默地掉回文档时间轴。
  • scroll() 看滚动容器,view() 看元素。 前者适合进度条和整页效果,后者适合入场、退场与途经视口的变化。
  • view() 默认 cover,入场通常要改成 entry。 比视口高的元素不要用 contain,它的区间会退化。
  • 区间之外的显示由 fill 决定。 用 both 保持状态;基础样式写成最终态,避免「动画没跑,内容也没了」。
  • 性能红利有前提。 只改可合成属性、滚动容器能走合成路径、动画数量可控,三条缺一条都可能掉回主线程。
  • 先保证没有动画时正确,再谈增强。 @supports 加 prefers-reduced-motion,是比版本判断更稳的降级方式。

参考来源#

CSS 滚动驱动动画:时间轴换成滚动进度后,duration、fill 与 range 都要重算
https://www.hehonglei.cn/posts/css-scroll-driven-animations-timeline-range/
作者
Honglei He
发布于
2026-11-10
许可协议
CC BY-NC-SA 4.0