CSS 滚动驱动动画实战:删掉 200 行 scroll 监听,进度条反而更跟手了

2026-09-29 0 567

上个月改一个文档阅读页面。这个页面的顶部有一根阅读进度条,随着用户往下滚,进度条从左到右填满。旁边还挂着一个章节导航,滚动到某一章的时候对应项要”亮起来”。页面底部有一句”已经到底了”的提示,快滑到底的时候淡入。

这套交互最早的实现是用 scroll 事件做的。一份主文件里散落着四个监听器,每次滚动都要读 scrollTop、算一次百分比、再改一遍 DOM 样式。为了怕抖动,作者还额外加了 requestAnimationFrame 去做节流。整个逻辑凑起来大概两百多行。

能跑,但有几个问题一直没解决。一是滚动的时候主线程稍微一忙,进度条就会轻微地卡一下,能明显感觉到不跟手。二是节流写的是 60fps,但用户滚动速度快的时候仍然会出现跳变,看起来不连续。三是移动端在惯性滚动阶段,事件的触发频率和滚动位置经常对不上,进度条的末端会提前卡住。

CSS 的滚动驱动动画(scroll-driven animations)在 Chrome 从 115 版本开始正式落地之后,一直在我待办清单上躺着。这次终于找到一个可以放心试的场景,把这段逻辑完整换成了 CSS。

先把概念说清楚

普通 CSS 动画的进度由时间驱动:animation-timeline: auto 意味着动画在 animation-duration 指定的时间里跑完。

滚动驱动动画把”时间”换成了”滚动位置”。也就是动画的进度由滚动条的位置决定,滚到哪动画跑到哪,不滚动画就停着。这听起来简单,但它保证了一件在 JS 方案里很难做好的事情:动画进度和滚动位置严格同步,同一帧渲染。

这个同步性是关键。JS 方案下,滚动事件触发 → 回调执行 → 样式更新 → 下一帧渲染,中间隔着好几步,任何一步的延迟都会体现在进度条上。CSS 方案下,滚动和动画在合成线程里一起完成,不会因为主线程忙而掉队。

CSS 提供了两种滚动时间线:

  • scroll():绑定到最近可滚动的祖先容器(或者指定的命名容器),进度从滚动开始到滚动结束。
  • view():绑定到元素自身进入和离开视口的过程。元素刚进入视口时进度 0,完全离开时进度 100%。

两种时间线覆盖了 90% 的场景。scroll() 用于”整体进度”类,比如阅读进度条;view() 用于”单个元素出入场”类,比如淡入、放大、位移。

案例一:把阅读进度条换掉

原来的结构大概是这样:

<div class="progress-track">
  <div class="progress-bar" id="bar"></div>
</div>

<article class="post">
  ... 很长的正文 ...
</article>

新写法不需要改 HTML:

.progress-track {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: rgb(15 23 42 / 0.06);
  z-index: 100;
}

.progress-bar {
  height: 100%;
  transform-origin: left center;
  scale: 0 1;   /* 起点是宽度为 0 */
  background: #3b82f6;

  animation: progress-fill linear forwards;
  animation-timeline: scroll(root);
}

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

scroll(root) 表示绑定到文档根滚动,是最常见的形式。整段代码没有一行 JS。

这里我用了 scale 而不是 width。原因很简单:width 会触发重排,scale 不会。虽然滚动驱动动画本身已经在合成线程里跑,但把绘制成本先压下来总没坏处。

另外一个容易忽略的细节是 transform-origin。默认是 50% 50%,也就是从中心缩放,进度条会从中间往两边展开,看起来很奇怪。改成 left center 才是想要的效果。

跑起来之后最直观的感受是:进度条和滚动条完全同步了。停在哪个位置,进度就走到哪,非常精确。用鼠标滚轮一格一格地滑,进度条一格一格地推,手感上像换了一个应用。

案例二:章节导航的高亮

原来高亮逻辑是用 IntersectionObserver 做的:监听每个章节的标题元素,进入视口就切换高亮。这套方案本身没问题,但它的高亮是”跳变”的,某章一进入视口整条导航就闪一下,切到下一章再闪一下。

用 view() 时间线可以做出一个更顺滑的效果,同时高亮和滚动本身完全同步:

.toc li {
  position: relative;
  padding-left: 12px;
  color: #64748b;
}

.toc li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 4px;
  bottom: 4px;
  width: 3px;
  border-radius: 2px;
  background: #3b82f6;
  opacity: 0;
  scale: 1 0.4;
  transform-origin: center;

  animation: toc-active linear both;
  animation-timeline: view();
  animation-range: entry 20% cover 60%;
}

@keyframes toc-active {
  0% {
    opacity: 0;
    scale: 1 0.4;
  }
  30%, 70% {
    opacity: 1;
    scale: 1 1;
  }
  100% {
    opacity: 0;
    scale: 1 0.4;
  }
}

这段 CSS 里面 animation-range 是关键。view() 默认的时间范围是元素”从刚进入视口到完全离开视口”,这个范围太宽了,用在高亮切换上会觉得指示条闪烁的时间过长。entry 20% cover 60% 的意思是:

  • entry 20%:从元素进入视口 20% 的位置开始
  • cover 60%:到元素离开视口 60% 的位置结束

这样指示条在元素刚露头的时候开始淡入,在元素挡住视口大部分的时候最亮,等元素滑出视口时再淡出。视觉上很自然。

animation-range 的取值有好几种形式,常用的有:

  • entry:元素从视口边缘进入,到完全进入视口
  • exit:元素从完全离开视口,到滑出视口边缘
  • cover:元素从进入视口边缘,到离开视口边缘(这是默认的全过程)
  • contain:元素完整可见的那一段

这些关键字后面可以跟百分比来指定具体位置,还有 entry-crossing、exit-crossing 这样的精确点。实际操作里最常用的就是 entry、exit 和 cover 三组关键字。

案例三:卡片滚进视口的入场动画

以前做卡片入场都是这么搞的:初始状态设 opacity: 0、translateY(20px),然后用 IntersectionObserver 检测到进入视口就加一个 class 触发 transition。这套方案的缺点是所有卡片的行为一致,第一个进入视口的卡片和最后一个距离滑入的距离一样,看起来有点机械。

用 view() 可以让每张卡片根据自己的位置”飘”进来:

.card {
  animation: card-in linear both;
  animation-timeline: view();
  animation-range: entry 10% entry 80%;
}

@keyframes card-in {
  from {
    opacity: 0;
    translate: 0 32px;
    scale: 0.96;
  }
  to {
    opacity: 1;
    translate: 0 0;
    scale: 1;
  }
}

entry 10% entry 80% 的意思是:卡片刚进入视口 10% 的时候开始动画,进入 80% 的时候动画结束。也就是说,卡片需要滚到比较深入视口的位置才完全显示出来。视觉上就是”慢慢浮现”的感觉。

这里有一个坑:没设置 animation-range 的话,默认用的是全范围(等价于 cover),动画会在卡片还没进入视口的时候就跑起来。因为元素在视口下方很远的地方其实已经和视口有交集了,只不过交集是”完全在下方”这种状态。所以每个入场动画几乎都要写 animation-range。

另一个坑是 both 这个关键字。它表示动画在时间线之外也保持最终状态(forwards)和初始状态(backwards)。如果只写 forwards,卡片在进入视口之前会短暂显示为正常状态(也就是 opacity 1),闪一下才开始淡入,看起来很突兀。滚动驱动动画的入场场景一定要用 both,这是最容易踩的坑之一。

案例四:横向滚动画廊的进度指示

页面里有一个横向滚动的产品画廊,用户滑动的时候下面的缩略图指示条也要跟着走。以前这个用 JS 单独算了一遍 scrollLeft / (scrollWidth - clientWidth),然后转成百分比给指示条。

现在直接把指示条挂到画廊的滚动时间线上:

.gallery {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  /* 给这个滚动容器一个名字 */
  scroll-timeline-name: --gallery-x;
  scroll-timeline-axis: x;
}

.gallery-indicator-bar {
  height: 3px;
  background: #3b82f6;
  transform-origin: left center;
  scale: 0 1;

  animation: gallery-progress linear forwards;
  animation-timeline: --gallery-x;
}

@keyframes gallery-progress {
  to {
    scale: 1 1;
  }
}

这里用了 scroll-timeline-name 来命名一个时间线,让非祖先元素也能引用它。当滚动容器和使用方不是父子关系的时候(比如指示条在画廊外面),就必须用命名时间线。

命名时间线有一个作用域的概念。默认情况下,命名的作用域是”它所在的元素及其后代”。如果指示条和画廊不在同一个祖先下,就得在两者共同的父元素上加 timeline-scope:

.gallery-wrapper {
  timeline-scope: --gallery-x;
}

这个名字没对上,动画会直接静默失效,找不到原因的时候最容易怀疑是浏览器不支持。我在这个上面花了将近一个小时,才发现是 timeline-scope 忘了写。

降级方案不能省

滚动驱动动画在 Chrome、Edge 上是完全可用的,Safari 和 Firefox 从今年的版本开始也在陆续跟进。但在不支持的浏览器上,这段 CSS 会直接失效——动画不会跑,元素最终停在初始状态。对于进度条这种初始 scale: 0 1 的元素来说,就等于永久隐藏了。

降级方案推荐用 @supports 判断:

@supports (animation-timeline: scroll()) {
  .progress-bar {
    scale: 0 1;
    animation: progress-fill linear forwards;
    animation-timeline: scroll(root);
  }
}

@supports not (animation-timeline: scroll()) {
  .progress-bar {
    /* 老浏览器里退回原始宽度,不显示动态进度 */
    scale: 1 1;
    opacity: 0.6;
  }
}

这样在不支持的浏览器上,进度条退化成一条静态的半透明装饰,页面是可读的、不会破相。等浏览器普及到一定程度,再把这个降级分支删掉。

如果你希望在不支持的浏览器上也保留真正的进度条,那就得写一份 JS 兜底。我实际的写法是把 JS 版和 CSS 版拆成两个文件,用同一个 @supports 判断来动态 import。CSS 支持的话就不加载 JS 那个模块,省掉一次网络请求和解析成本。

几个值得提前知道的细节

滚动驱动动画和 prefers-reduced-motion。 这一点经常被忽略,但很重要。对于有前庭功能敏感的用户,滚动相关的持续动画可能引起不适。推荐在动画外层套一层媒体查询:

@media (prefers-reduced-motion: reduce) {
  .progress-bar,
  .card,
  .toc li::before {
    animation: none;
  }
  .progress-bar {
    scale: 1 1;
    opacity: 0.5;
  }
}

时间线不是万能的。 scroll() 只有一个时间线,它表达的是”整个滚动容器从开始到结束”。如果你需要”元素 XX 滚到 XX 位置”这种精确触发,它做不到。这种就得用 view() 或者干脆用 ScrollTimeline JavaScript API 手动构造时间线。

view() 的祖先选择器有讲究。 默认情况下,view() 观察的是最近的可滚动祖先。如果元素的祖先里有多个滚动容器(比如页面本身可滚,里面还有一个可以横向滚的卡片列表),行为可能和你想的不一样。想明确指定观察哪个滚动祖先,可以用 view(--my-scroller) 引用命名时间线。

调试工具。 Chrome DevTools 里在 Elements 面板选中一个使用滚动驱动动画的元素,右侧的 Styles 面板会看到动画旁边多了一个小圆圈图标,点开可以看动画时间线详情。Animations 面板里也能把滚动时间线当成一条时间轴来拖拽预览,调 animation-range 的时候特别好用。

改造完的结果

整个文档阅读页面改完之后:

  • 删掉了 4 个 JS 监听器和一段 rAF 节流逻辑,一共 210 多行
  • 滚动时进度条的帧率从 48fps 左右稳定到 60fps 满帧
  • Mid-range Android 设备上滚动卡顿率下降了一半以上
  • 移动端的惯性滚动阶段,进度条位置和手指位置始终对齐,不再出现尾部卡顿

我最想强调的其实不是性能指标。是那段”事件监听 + 节流 + 手动算百分比”的代码没了。以前维护这段逻辑,得考虑”什么时候需要重新绑定监听”、”移动端怎么处理触摸滚动和惯性滚动”、”SSR 场景下怎么处理初始状态”这些问题。现在这些全都不需要了。代码变少,出问题的面也跟着变少。

什么场景不要用滚动驱动动画

用了几个案例之后,也总结出几个不值得用的场景,分享出来省点弯路。

第一,动画需要和滚动位置解耦的时候。 比如”滚到页面 80% 的位置弹出一个订阅框”,这种和”位置”绑定、但不需要”随时变化”的场景,用 IntersectionObserver 更合适。滚动驱动动画的本质是”随时映射”,用在一次性触发上是大材小用,还给合成线程增加负担。

第二,需要根据滚动方向做不同动画的时候。 scroll() 和 view() 都是和位置绑定的,往下滚和往上滚,只要位置一样,动画进度就一样。如果要区分方向,还得靠 JS。

第三,需要精确控制”惯性滚动结束后”触发的时候。 滚动驱动动画在滚动过程中一直在跑,无法区分”用户正在拖”还是”惯性滑动”。这类场景还是 scrollend 事件加一点 JS 更靠谱。

沿用我改这个页面时的一个判断:如果一段 JS 是为了”让样式跟上滚动位置”,那基本就可以用滚动驱动动画替换;如果它包含”判断、决策、状态机”这类逻辑,那还是留在 JS 里。

写在最后

CSS 这两年更新的很多特性,路线其实是一致的:把过去”必须用 JS 做”的事情挪回声明式。滚动驱动动画、锚点定位、容器查询、:has() 选择器,都是同一个方向上的东西。

这个方向对前端工程师来说,意味着要学的东西变得更多了——过去一个滚动进度条就是几行 scrollTop 除法,现在要理解时间线、作用域、动画范围、降级策略这一整套概念。但一旦学会了,写出来的代码会更短、更稳、更不容易出 bug。

如果你手上也有那种”能用但总觉得有点卡”的滚动交互,可以拿一个最简单的场景先试试。改一个进度条、或者改一组入场动画,感受一下那种”和滚动完全同步”的感觉。你会发现这种”顺”是以前靠 JS 节流再怎么优化也做不出来的。

CSS 滚动驱动动画实战:删掉 200 行 scroll 监听,进度条反而更跟手了
收藏 (0) 打赏

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

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

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

淘吗网 css CSS 滚动驱动动画实战:删掉 200 行 scroll 监听,进度条反而更跟手了 https://www.taomawang.com/web/css/2839.html

常见问题

相关文章

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

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