目录
一、它不报错,只是没按你想的跑
先看一段很自然的写法:
.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); }}这里有三个刻意的选择:
- 不写
duration。 在进度时间轴上,时间值不参与计算。写2s还是200ms,结果都一样;进度条走多快,取决于页面有多长。 - 只改
transform。scaleX()可以由合成线程处理。如果改成width: 0%到width: 100%,滚动每移动一像素都要重新布局。 - 基础样式先写成「没有动画也正确」的样子。 不支持
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-direction | alternate、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 的实现会漏掉其中的大部分。
十、渐进增强与可访问性
滚动驱动动画的降级策略可以归纳成三条:
- 静态样式先正确。 先写没有动画时的最终布局与可见性,再用
@supports叠加起点。内容不能依赖动画才出现。 - 用能力检测,不用版本号。
@supports (animation-timeline: view())比判断浏览器版本可靠;同一个浏览器的不同平台也可能不同。 - 尊重
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,是比版本判断更稳的降级方式。