CSS 滚动驱动动画实战:用 animation-timeline 改写阅读进度、入场浮现与滑动指示

2026-10-14 0 286

做“滚动到某处就播动画”这类需求,过去几乎只有一个套路:IntersectionObserver 监听元素,回调里加一个 class,剩下的交给 CSS transition 收尾。能用,但它给回来的东西很有限——进去了或者没进去,一个布尔值。一旦动画进度需要跟滚动位置同步推进(进度条、视差、横向相册的联动指示器),就只能回到 scroll 事件里手动算 scrollTop / (scrollHeight - clientHeight),再把结果写成自定义属性。

CSS 滚动驱动动画把这层计算收回到样式层:滚动容器的偏移量或者元素在视口里的可见程度,本身就是一条时间轴。你只需要声明“这条动画由哪条时间轴驱动”,剩下的映射、合成、跟手同步,浏览器自己处理。

下面用三个真实场景把它讲透。三段代码都可以直接抄进项目,按顺序看完基本就够用了。

一、先弄清语法骨架

滚动驱动动画本质上没有引入新属性,只是给现有的 animation 换了一条时间轴来源:

.target {
  animation: my-keyframes linear both;
  animation-timeline: <timeline>;
  animation-range: <start> <end>;
}

时间轴有两个来源:

  • scroll()——以某个滚动容器的滚动偏移量作为进度。参数是「哪个容器 + 哪个方向」,比如 scroll(root block) 表示文档根容器的纵向滚动,scroll(nearest inline) 表示最近的可滚动祖先的横向滚动。
  • view()——以元素自身在滚动端口里的可见程度作为进度。元素刚露头是 0,完全进入可见区域是某个中间值,开始离开到彻底离开是后半段。

animation-range 用来裁剪时间轴上真正生效的那一段。常用的四个命名区间是 cover、contain、entry、exit,每个都可以再带百分比或长度做偏移。记不住具体数值没关系,记住一句话就行:entry 管“露出来”的过程,exit 管“走出去”的过程。

有个细节必须提醒:滚动时间轴下,animation-duration 保持初始值 auto,动画的“时长”由时间轴本身决定。别手滑写上 1s,那等于把动画和滚动位置之间的关系切断了,写出来的效果会非常诡异。

还有一条容易踩的坑:animation 简写会把 animation-timeline 一起重置掉。所以简写要写在前面,时间轴声明放在后面,顺序反了就白写。

二、案例一:顶部阅读进度条

这是最典型的一个场景。以前要在滚动事件里不断更新 transform: scaleX(),现在只需要让 scroll(root block) 直接接管:

@keyframes progress-grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.reading-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 3px;
  background: linear-gradient(90deg, #e8590c, #f08c00);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

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

这里有两个刻意的设计。

第一,动画属性不是直接写在基础样式里,而是包在 @supports 里。默认状态下进度条是 scaleX(0),也就是完全不可见——不支持的浏览器不会看到一根永远卡在 0 的横条,视觉效果干净。这就是渐进增强该有的样子:不支持的浏览器拿到的是“没有这个功能”,而不是“这个功能坏了”。

第二,transform-origin 设成了 0 50%。缩放的原点默认在中心,不改成左边的话,进度条会从中间往两边同时长,观感很别扭。

另外,用 scaleX 而不是改 width,是为了让整个动画停留在合成层,不触发重排。这个习惯从老式动画一直延续到现在,依然成立。

三、案例二:卡片进入视口时自动浮现

卡片、列表项、图片这些“滚到才出现”的元素,用 view() 比 scroll() 更合适,因为你不用关心它们到底在页面的第几个像素,只关心它们和视口的关系。

@keyframes card-reveal {
  from {
    opacity: 0;
    translate: 0 40px;
  }
  60% {
    opacity: 1;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

.card {
  opacity: 1;
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .card {
      animation: card-reveal linear both;
      animation-timeline: view();
      animation-range: entry 10% entry 70%;
    }
  }
}

逐段拆开看。

animation-range 为什么是 entry 10% entry 70%? entry 0% 是元素刚开始碰到滚动端口边缘的那一刻,entry 100% 是元素完全进入可见区域的那一刻。写成 10% 到 70%,意味着元素露出一点点之后才开始动,露出七成左右就运动完毕。这样做的好处是动画结束得早,用户视线跟上来的时候元素已经稳定,不会有“边看边晃”的割裂感。如果直接写 entry 0% entry 100%,动画会一直拖到元素完全进入才停,节奏偏慢。

为什么 keyframes 里多加了一个 60% 的关键帧? 位移和透明度如果在同一区间线性完成,视觉上会显得拖沓。让透明度提前在 60% 就走完,位移继续走到 100%,观感上会利落很多。这是纯手感问题,不是规范要求,但值得养成习惯。

opacity 和 translate 这两个属性都跑在合成层上,动画过程中不会引发重排重绘,滚动时掉帧的概率大大降低。

最外层为什么要限 prefers-reduced-motion? 动画本身不剧烈,但凡是滚动联动的位移效果,都应该尊重系统里的“减少动态效果”设置。用户开了这个开关,说明他们真的不想看。基础样式里 opacity: 1 保证了此时元素直接可见。

还有一个隐含的好处:如果某个卡片加载时就已经在首屏、完全可见,它的时间轴进度已经超过 entry 100%,配合 both 填充模式,它会稳稳停在最终状态,不会出现“首屏元素永远不显示”的事故。这比手写 IntersectionObserver 时忘记处理初始状态要省心得多。

四、案例三:横向相册联动的进度指示器

这个场景稍微绕一点:滚动容器是一个横向的相册,进度指示器却在容器外面。这时候需要把时间轴“命名”出来,再跨元素引用。

<div class="gallery-wrap">
  <div class="gallery">
    <figure>…</figure>
    <figure>…</figure>
    <figure>…</figure>
  </div>
  <div class="gallery-bar"><span class="gallery-bar__fill"></span></div>
</div>
@keyframes progress-grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.gallery-wrap {
  /* 把时间轴的名字提升到共同祖先上 */
  timeline-scope: --gallery;
}

.gallery {
  display: flex;
  gap: 12px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-timeline: --gallery inline;
}

.gallery > figure {
  flex: 0 0 72%;
  margin: 0;
  scroll-snap-align: center;
}

.gallery-bar {
  height: 2px;
  background: #e9ecef;
  overflow: hidden;
}

.gallery-bar__fill {
  display: block;
  height: 100%;
  background: #e8590c;
  transform: scaleX(0);
  transform-origin: 0 50%;
}

@supports (animation-timeline: scroll()) and (timeline-scope: --gallery) {
  .gallery-bar__fill {
    animation: progress-grow linear both;
    animation-timeline: --gallery;
  }
}

关键在 timeline-scope 这一行,它是最容易被忽略、也最容易让人怀疑“代码写错了”的地方。

命名滚动时间轴(scroll-timeline: --gallery inline)默认只对定义它的元素及其后代可见。而这里的进度条是相册的兄弟节点,直接写 animation-timeline: --gallery 是找不到这条时间轴的,浏览器会安静地忽略——不会报错,进度条就永远停在 scaleX(0),排查起来很费时间。

解决办法就是在两者共同的父元素上写一句 timeline-scope: --gallery,把名字的作用域提升到整棵子树。这样兄弟节点之间也能通过名字互相引用。

顺带说,scroll-timeline: --gallery inline 里的 inline 表示横向(书写方向所在的轴)。如果项目有 RTL 需求,用 inline 比写死 x 更稳妥。

五、几个真正会卡住人的坑

1. 简写把时间轴吃掉了

/* 错误:animation 简写会把 animation-timeline 重置为 auto */
.target {
  animation-timeline: view();
  animation: fade-in linear both;
}

/* 正确:简写在前,时间轴在后 */
.target {
  animation: fade-in linear both;
  animation-timeline: view();
}

规则很简单:animation 简写会重置所有动画相关的子属性,包括时间轴和 animation-range。凡是用了简写,时间轴和区间声明就必须写在它后面。

2. 忘了填 fill-mode

滚动驱动动画的进度是双向的。滚动条往回拉,进度就会倒退。如果不写 both 或 forwards,元素在时间轴还没推进到动画区间时会直接显示成初始状态——该隐藏的元素会一直杵在那里。养成 linear both 的固定搭配,能省掉很多“为什么它闪了一下”的困惑。

3. 祖先元素的 overflow 把 view() 带偏了

view() 判断的是“元素相对最近滚动容器”的可见程度。如果某个祖先元素上有 overflow: hidden,它就成了那个“最近滚动容器”。一个不可滚动的容器当时间轴,进度基本锁死在某个值不动,动画看起来就像没生效。

遇到 view() 不工作的情况,第一件事就是沿着 DOM 往上翻,看看有谁写了 overflow: hidden。同样的道理也适用于 scroll(nearest …),把 nearest 换成明确的 root 或具名时间轴,往往问题就没了。

4. 页面根本没有滚动距离

如果整个文档的高度和视口一样高,scroll(root block) 的时间轴长度是零,进度条会停在起点。这不算 bug,是数学问题。开发时先把内容撑起来再调效果。

六、兼容性与降级策略

截至现在,Chromium 系从 115 版本开始原生支持 scroll() 和 view(),Safari 在最近几个大版本里也陆续补齐,Firefox 的进度稍慢一些。具体版本号没必要背,写代码时让浏览器自己回答就行——用 @supports 做特性查询,支持就开,不支持就退。

这套写法之所以值得推广,很大程度上就是因为降级成本几乎为零。你不需要引入任何 polyfill,也不需要在 JS 里判断浏览器:

/* 1. 先写一个不需要动画也能用的默认样式 */
.card {
  opacity: 1;
}

/* 2. 再在能力检测里叠加动画 */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .card {
      animation: card-reveal linear both;
      animation-timeline: view();
      animation-range: entry 10% entry 70%;
    }
  }
}

这个顺序很重要:默认样式负责“功能完整”,@supports 负责“锦上添花”。反过来写(先默认隐藏,再靠动画显示)在旧浏览器上就是一片空白,这是最常见的翻车方式。

七、什么时候不该用它

滚动驱动动画不是万能的,有几种情况老老实实用 JS 更合适。

一是动画需要根据业务状态动态改变终止位置,比如进度条要停在某个后端返回的百分比上。CSS 时间轴表达不了这种依赖,用自定义属性配合 animation-range 虽然能撑一阵,但代码会越来越难读。

二是需要在动画过程中读取实时进度做其他运算,比如滚到一半时触发一次埋点。CSS 不提供回调,这种需求还是得回到滚动事件里。

三是动画对象数量极多、且每个都要独立时间轴时,几百个 view() 时间轴同时存在,性能表现值得先压测一轮再上线。

其余的“滚动播放动画”场景——进度条、入场浮现、视差、横向联动指示器、滚动吸顶切换——都可以放心交还给 CSS。代码量少一大截,掉帧的概率也更低。

写在最后

回头看这三个案例,最大的变化其实不在代码写法,而在心智模型。以前我们思考的是“滚动事件触发了什么”,现在思考的是“这个元素处在这条时间轴的哪个位置”。后者更接近浏览器的本意,也更少出错。

如果现在手里正好有一个靠 scroll 事件驱动的效果,可以挑一个最简单的先改成 animation-timeline,把滚动事件监听删掉,实际跑一遍再做决定。手感上的差别,比看十篇文章都直观。

CSS 滚动驱动动画实战:用 animation-timeline 改写阅读进度、入场浮现与滑动指示
收藏 (0) 打赏

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

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

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

淘吗网 css CSS 滚动驱动动画实战:用 animation-timeline 改写阅读进度、入场浮现与滑动指示 https://www.taomawang.com/web/css/2936.html

常见问题

相关文章

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

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