CSS 滚动驱动动画实战:animation-timeline 做进度条与元素入场

2026-10-21 0 772

顶部阅读进度条、往下滚时卡片一个个浮上来、横向滑动的画廊里图片跟着缩放——这几类效果以前基本都得住进 JavaScript 里:监听 scroll、算 getBoundingClientRect、再塞个 class。代码不难,但麻烦,而且每次滚动都触发一次读写,卡不卡全看运气。

现在这套活可以直接在 CSS 里干完。核心只有三个东西:animation-timeline、animation-range,以及一个能被命名的时间线。本文从最基础的进度条开始,一路写到命名时间线,中间把几个容易踩的坑说清楚。

一、先理解”时间线”这个词在说什么

平时写 @keyframes 加 animation: fade 0.3s,动画的进度来源是文档时间轴——也就是挂钟。0 到 0.3 秒之间,进度从 0% 走到 100%,跟页面滚到哪儿一点关系都没有。

滚动驱动动画做的事情,是把进度来源换掉。同一个 @keyframes,进度不再来自时间,而是来自滚动位置:滚到某个位置是 0%,再滚到某个位置是 100%,中间线性插值。

这就带来一个必须提前接受的性质:它是可逆的。你往回滚,动画就倒放,不会”播完就结束”。同时它没有时长概念,写 animation-duration 是没用的,写了也不会报错,但就是不起作用。

CSS 提供了两种时间线:

  • scroll():把某个滚动容器的滚动位置当作进度。滚动条从最顶到最底,就是 0% 到 100%。
  • view():把自己(或者自己指定的元素)在视口中的可见程度当作进度。元素刚碰到视口下沿是 0%,完全离开视口上沿是 100%。

前者适合”整个页面的进度”,后者适合”这个元素自己的进出场”。

二、案例:一条不占 JS 的阅读进度条

先来最简单的。HTML 只有一个空元素,挂在页面最前面:

<div class="read-progress"></div>

样式部分:

.read-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 3px;
  background: #e5484d;
  transform-origin: 0 50%;
  scale: 0 1;
  animation: progress-fill linear both;
  animation-timeline: scroll(root block);
}

@keyframes progress-fill {
  from { scale: 0 1; }
  to   { scale: 1 1; }
}

逐行拆一下。

scroll(root block) 的意思是:取根滚动容器(也就是整个文档)在块轴方向上的滚动进度。如果你希望进度条跟随的是某个内部滚动区域,把 root 换成 nearest 或改成命名时间线即可。

动画本身只动 scale,不动 width。原因很实在:scale 走的是合成层,width 会触发布局。一条每帧都在变宽的元素,在低端设备上把主线程拖住是很常见的事。用 scale 的话,进度条变长这件事交给 GPU,主线程基本无感。

但用 scale 就必须配 transform-origin: 0 50%。默认的缩放中心在元素正中,不加这一行,你会看到进度条从中间往两边长,视觉上像个诡异的光标。这个坑几乎每个人第一次都会踩。

另外注意 animation 简写里带了 both。滚动驱动动画强烈建议显式给 animation-fill-mode: both,否则在时间线还没激活的那一小段,属性值可能不按你预期应用,出现”滚到某个位置突然跳一下”的现象。

三、元素入场:view() 和 animation-range

进度条是全局的,元素入场是局部的,这里换 view()。

<section class="reveal">...</section>
.reveal {
  animation: rise-in linear both;
  animation-timeline: view();
  animation-range: entry 10% entry 60%;
}

@keyframes rise-in {
  from {
    opacity: 0;
    translate: 0 60px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

view() 把元素自己的可见过程拆成了四个区间,配 animation-range 使用:

  • cover:从元素下沿刚接触视口下沿,到元素上沿离开视口上沿。这是最长的区间,也是默认值。
  • entry:元素开始进入视口,到元素完全进入视口。
  • exit:元素开始离开视口,到完全离开。
  • contain:元素完全被视口包住的那一段(元素比视口高的情况下这个区间可能是反的)。

上面写的 entry 10% entry 60%,意思是:元素刚进入视口 10% 的时候动画开始,进入 60% 的时候动画完成。这样做的效果是——元素露出一点点的时候还是半透明,等它大部分都进到视口里,已经稳稳落位了。如果直接用默认的 cover,动画会被拉得很长,元素从出现到离开一直在动,看起来反而像卡了。

这里有一个很多人第一次用会疑惑的现象:如果某个 .reveal 元素一进页面就已经在视口里(比如首屏的标题),view() 的进度会直接落在 entry 100% 之后,动画瞬间就是完成态。这正是我们想要的——首屏内容不会因为等待滚动而隐身。所以不要为了”保险”去写额外的 JS 判断。

顺带一提,translate 和 scale 这类独立变换属性现在可以单独写,不用再拼 transform: translateY(60px)。好处是和别的变换互不干扰,你可以在同一个元素上同时用 rotate 和 translate,不会打架。

四、命名时间线:让子元素跟着某个容器滚

前两个例子的时间线是”隐式”的:scroll() 找最近的滚动祖先,view() 找自己。但真实项目里经常需要”让这批子元素跟着这个容器的滚动进度走”,这时候要用命名时间线。

典型场景是一个横向滚动的图片画廊,图片随着横向滚动进程依次放大:

<div class="gallery">
  <img class="shot" src="a.jpg" alt="">
  <img class="shot" src="b.jpg" alt="">
  <img class="shot" src="c.jpg" alt="">
</div>
.gallery {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-timeline: --gallery inline;
}

.gallery .shot {
  flex: 0 0 auto;
  width: 280px;
  animation: pop linear both;
  animation-timeline: --gallery;
  animation-range: 0% 60%;
}

@keyframes pop {
  from {
    scale: 0.82;
    opacity: 0.35;
  }
  to {
    scale: 1;
    opacity: 1;
  }
}

关键的两行:滚动容器上用 scroll-timeline: --gallery inline 声明并命名一条时间线,轴向是 inline(横向文字流方向);子元素上用 animation-timeline: --gallery 引用它。

注意这里的 animation-range: 0% 60%,是相对整条横向滚动范围的百分比,不是相对视口。也就是说横向滚到 60% 的位置时,所有图片都完成了动画。如果你希望每张图各自有独立的节奏,就不要用命名滚动时间线,改用 view-timeline 给每个 .shot 命名,或者干脆回到 view()。

命名时间线还有一个容易忽略的限制:它只对声明容器的后代生效。如果动画元素是那个滚动容器的兄弟节点,什么都引用不到,动画会静默失效——不报错,只是不动。这种情况需要 timeline-scope 把名字提升到共同祖先上,属于比较少见但确实会遇到的状况。

五、几个必须提前知道的坑

1. animation 简写会覆盖掉 animation-timeline

这是最容易浪费半小时的一条。下面两行写在同一个规则里,顺序不同,结果完全不同:

/* 正确 */
.a {
  animation: rise-in linear both;
  animation-timeline: view();
}

/* 错误:简写把 animation-timeline 重置回 auto,动画退回按时间播放 */
.b {
  animation-timeline: view();
  animation: rise-in linear both;
}

因为 animation 简写会把所有没写出来的子属性重置为初始值,而 animation-timeline 的初始值恰好是 auto。所以永远把 animation-timeline 放在 animation 之后。

2. 时间线必须挂在真正的滚动容器上

给一个 overflow: visible 的元素写 scroll-timeline,等于什么都没发生。滚动容器得有实际溢出内容、有确定的滚动范围,时间线才有进度可言。内容不够长、压根没法滚的时候,进度会是一个固定值,动画就停在某一帧——看起来像”动画坏了”,实际是没得可滚。

3. 别在滚动驱动动画里指望 transition

滚动驱动动画每一帧的值都由滚动位置直接决定,它不是一个”播放过程”。给参与的元素再加 transition,只会让浏览器在每帧插入一个过渡插值,产生拖尾和抖动的观感。想要平滑,调 animation-range 的区间,不要叠 transition。

4. 可访问性:别让动效变成骚扰

滚动驱动的位移和缩放对前庭功能敏感的用户并不友好。记得留一个开关:

@media (prefers-reduced-motion: reduce) {
  .reveal,
  .gallery .shot {
    animation: none;
    opacity: 1;
    translate: none;
    scale: none;
  }
}

进度条本身建议保留,它是信息而非装饰,去掉反而损失了功能。

5. 和 position: sticky 组合时注意层叠上下文

一个 position: sticky 的元素如果同时挂了滚动驱动动画,动画产生的 scale 或 opacity 会创建新的层叠上下文,可能让它的吸顶行为和 z-index 表现跟你原本的计算不一样。遇到”sticky 突然不吸顶了”,先看看是不是动画引入的合成层在捣乱。

六、降级:先能用,再好看

浏览器支持这块,Chromium 内核早就默认开启,Safari 在较新版本里也补齐了,Firefox 相对保守一些。所以发布前建议先查一遍当前的支持矩阵,别只看自己机器上的效果。

降级的思路不是”检测到不支持就加一堆 JS 补丁”,而是先保证没有动画也是完整可读的页面,再用 @supports 把动画叠上去。

/* 默认状态:元素正常可见,不依赖任何动画 */
.reveal {
  opacity: 1;
}

@supports (animation-timeline: view()) {
  .reveal {
    animation: rise-in linear both;
    animation-timeline: view();
    animation-range: entry 10% entry 60%;
  }
}

这样写的好处是,不支持的环境下页面完全正常,不会出现”内容全是透明的”这种灾难。很多团队做动画降级翻车,都是因为在基础样式里就写了 opacity: 0,把可见性押在了动画上。

七、什么时候还是该用 JavaScript

滚动驱动动画解决的是”位置到属性”的映射,它不解决逻辑。以下情况老老实实回到 IntersectionObserver 或者动画库:

  • 元素进入视口时只触发一次,之后永远不再变(比如埋点、一次性加载)——这本质上是事件,不是动画。
  • 动画需要和其他状态联动,比如播放视频、切换音频、更新计数器。
  • 需要精确的缓动曲线控制,或者多段编排、时间线串联。
  • 需要根据滚动速度做动态响应(CSS 时间线不暴露速度)。

判断标准其实很朴素:如果你要的效果,用一句话就能描述成”滚到 X 位置时属性变成 Y”,那交给 CSS;一旦这句话里出现了”如果””但是””同时还要”,就该考虑 JS 了。

把这些拼起来,一个纯 CSS 的滚动叙事页面已经能覆盖大部分需求了。真正需要注意的是那几条坑:顺序、容器、区间,以及别忘了给不想看动效的人留条路。

CSS 滚动驱动动画实战:animation-timeline 做进度条与元素入场
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 css CSS 滚动驱动动画实战:animation-timeline 做进度条与元素入场 https://www.taomawang.com/web/css/2966.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务