CSS 滚动驱动动画实战:用 scroll() 和 view() 做阅读进度条、入场渐显与目录高亮

2026-09-14 0 876

几年前做过一个内容站,页面上有阅读进度条、图片滚到视口时渐显、右侧目录跟着滚动高亮。三样东西拆开写都不难,但凑在一起就是一大堆重复的 scroll 监听加上 getBoundingClientRect 调用。每次滚动都在读布局属性,性能分析里那一条条强制重排的红线看着都难受。

后来我知道了 passive: true,知道了 IntersectionObserver,知道要把读和写拆到 rAF 里。这些确实能治标,但代码量摆在那儿,我每次写都得重新想起来一遍。

滚动驱动动画(Scroll-driven Animations)是把这件事一次性解决的方案:动画的进度不再由时间推进,而是直接由滚动位置决定。你把 keyframes 写出来,剩下的交给浏览器。

先看那个最小的例子

一个阅读进度条,HTML 只需要一行:

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

CSS 是这样的:

.progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 3px;
  background: #2f6fed;
  transform-origin: 0 50%;
  animation: grow linear;
  animation-timeline: scroll(root block);
}

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

就这样,没有任何 JavaScript。往下滚的时候进度条变长,往上滚变短,滚到页面底部刚好铺满。

这里最值得看的是 animation-timeline: scroll(root block)。这句话的意思是:这个动画的时间轴不是墙上时钟,而是根元素在块轴方向上的滚动进度。滚到 0 的时候动画进度是 0%,滚到底的时候是 100%,中间线性对应。

第一个反直觉的地方:animation-duration 会失效

上面那段 CSS 里我写了 animation: grow linear,没有写时长。如果写成 animation: grow 2s linear,那个 2s 其实是没用的。

原因很简单:时间轴已经不是时间了,时长这个概念也就失去了意义。规范里说得很清楚,滚动驱动动画的时长被当作 auto 处理,animation-delay 同理。你不用去删它,浏览器会忽略。

这也意味着,你没法用 animation-duration 来控制动画”跑多快”。想让它慢一点,靠的是拉长滚动距离,或者用下一节讲的 animation-range 去限制它生效的区间。

顺便说一句,我给进度条用的是 transform: scaleX() 而不是 width。这不只是习惯问题——滚动驱动动画如果作用在 width 这类会触发布局的属性上,每一帧都得走主线程重新算布局。滚动的时候这个开销会非常明显。而 transformopacity 是在合成器线程上跑的,滚动起来完全不掉帧。这一点在滚动驱动动画里比在普通动画里更重要,因为滚动事件本身就很密集。

view() :让元素”滚动到视口时才动”

进度条用的是 scroll(),它跟的是滚动容器。另一个常用的时间轴叫 view(),它跟的是元素自身在视口里的位置。

举个例子,正文里的图片希望是滚到眼前时淡入上来:

.article img {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 60%;
}

@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(24px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

view() 生成的这条时间轴的进度是这样定义的:当元素的顶边刚碰到视口底边时是 0%,当元素的底边越过视口顶边时是 100%。默认范围是 cover,也就是整个从进入到完全离开的过程。

我上面用 animation-range: entry 0% entry 60% 把区间压到前 60% 的入场阶段。意思是图片从刚开始进入视口,到它进了差不多六成的时候,动画就跑完了。这样图片还没滚到画面正中间就已经完全显示出来了,观感上更”稳”一点。如果直接用默认的全区间,图片会在滚出视口的时候反向淡出,那就很怪了。

animation-range 的四个命名区间

这块是新手最容易绕晕的地方,但只要记住 view() 时间轴上有四个命名区间就好理解了:

  • cover:完整区间,元素从”刚看见一点”到”完全离开”,也就是 0% 到 100%
  • entry:元素从视口底边进入到完全进入视口
  • exit:元素从开始离开视口上边到完全离开
  • contain:元素完全在视口内的那一段(元素比视口高的时候,这个区间可能是空的)

每个名字后面还能带百分比,表示在这个子区间里再切一刀。entry 0% entry 60% 就是”入场阶段的前六成”。

有个坑得特别小心:这些命名区间只对 view() 有效,写在 scroll() 上是无效的。 我给一个滚动容器写 animation-range: entry 0% exit 100% 的时候,浏览器不报错,只是整条声明被判定为无效直接丢弃,动画默默回到默认范围。查了半天才发现是关键字用错了地方。在 scroll() 上你只能用百分比或者具体长度,比如 animation-range: 0% 40% 或者 animation-range: 0 120px

顶部导航随滚动收缩

这个用 scroll() 加上长度区间最合适。需求是:页面滚过前 120 像素的时候,顶部导航从 72px 高缩到 52px。

.site-header {
  position: sticky;
  top: 0;
  animation: shrink linear both;
  animation-timeline: scroll(root block);
  animation-range: 0 120px;
}

@keyframes shrink {
  from { padding-block: 20px; }
  to   { padding-block: 10px; }
}

注意这里我动的是 padding,属于触发布局的属性,主线程每一帧都要算。120 像素的区间很短,实际影响可以接受。但如果你要收缩的是一个很复杂、节点很多的头部,最好改成对高度无关的属性下手,比如变一下内部元素的 transform: scale(),或者干脆把收缩效果放在一段背景遮罩上。

另一个细节是 position: stickyposition: fixed 的区别。用 scroll() 时间轴去驱动一个 fixed 元素是没问题的,因为固定的只是它的位置,动画进度还是由容器滚动决定。但如果你给一个 fixed 元素配 view() 时间轴,那就是白费力气——它永远钉在一个地方,压根不会在视口里移动,时间轴进度会一直卡在某个值上不动。

给自定义滚动容器加时间轴

上面几个例子的 scroll(root) 都是指文档根节点的滚动。如果滚动发生在一个自己写的容器里,比如一个横向的卡片轮播,就得用命名时间轴了。

<div class="carousel">
  <div class="track">
    <article class="card">…</article>
    <!-- 更多卡片 -->
  </div>
</div>
.carousel {
  overflow-x: auto;
  scroll-timeline-name: --deck;
  scroll-timeline-axis: inline;
}

.card {
  animation: pop linear both;
  animation-timeline: --deck;
  animation-range: entry 0% entry 40%;
}

scroll-timeline-name 写在滚动容器上,给这条时间轴起个名字;scroll-timeline-axis: inline 说明我们跟踪的是横向(行内轴)滚动,不写的话默认是块轴(纵向),那这里就完全不动了。

有个查找规则要记住:animation-timeline 引用名字的时候,是沿着元素的祖先链往上找的,找到第一个匹配的滚动容器就用它。所以如果页面上有两个轮播,各自起了不同的名字,就不会互相串。

但如果被动画的元素本身就在滚动容器外面,比如导航区的指示点要跟着轮播走,那就得用 timeline-scope 来扩大这条时间轴的可见范围。

timeline-scope:让”非亲非故”的元素也能用上

这个属性是我觉得整块规范里设计得最巧妙、也最容易被忽略的地方。

假设页面结构是这样的:文章正文和右侧目录是两个并列的兄弟节点,目录里每一项要跟着对应章节的高亮走。问题是,目录项不是章节的后代,按上面的查找规则,它压根看不到章节定义的那条时间轴。

<div class="layout">
  <nav class="toc">
    <a id="toc-1" href="#sec-1" rel="external nofollow" >第一章</a>
    <a id="toc-2" href="#sec-2" rel="external nofollow" >第二章</a>
  </nav>
  <article>
    <section id="sec-1">…</section>
    <section id="sec-2">…</section>
  </article>
</div>

解决方式是:在共同祖先上声明 timeline-scope,把这些名字”提升”到这个层级,让它的所有后代都能看到。

.layout {
  timeline-scope: --sec-1, --sec-2;
}

#sec-1 {
  view-timeline-name: --sec-1;
  view-timeline-axis: block;
}
#sec-2 {
  view-timeline-name: --sec-2;
  view-timeline-axis: block;
}

#toc-1 { animation-timeline: --sec-1; }
#toc-2 { animation-timeline: --sec-2; }

规则是这样的:timeline-scope 里列出的名字,可以被声明它的元素的所有后代引用;而这些时间轴的定义元素必须是它的后代。上面这段代码里,#sec-1.layout 的后代,#toc-1 也是,两边都能看到这个名字,链接就建立起来了。

接下来是给目录项加高亮指示条:

#toc-1::before,
#toc-2::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  background: #2f6fed;
  transform-origin: 0 50%;
  animation: mark linear both;
}

#toc-1::before { animation-timeline: --sec-1; animation-range: entry 30% exit 70%; }
#toc-2::before { animation-timeline: --sec-2; animation-range: entry 30% exit 70%; }

@keyframes mark {
  0%   { transform: scaleY(0); }
  12%  { transform: scaleY(1); }
  88%  { transform: scaleY(1); }
  100% { transform: scaleY(0); }
}

这里用的是 keyframe 里的”平台期”技巧:动画两头分别是 0% 和 100% 的状态,中间 12% 到 88% 保持满值。配合 entry 30% exit 70% 的区间,效果就是”章节进入视口一段之后高亮亮起,快离开时淡出”。

说实话,这个方案只能算近似。因为每条时间轴的范围是章节自身的高度决定的,短章节和长章节跑完动画的时机不一样,各个百分比需要你手动调。真正要做到”当前阅读到哪一段就高亮哪一条”,还是得靠滚动偏移量来算。但对于大部分长篇内容来说,调一调就够了,而且调完之后页面上没有任何 JS 在监听滚动。

降级:不能装作没这回事

浏览器支持情况大概是这样的:Chrome 从 115 开始就有了,Edge 跟得很紧,Safari 到 26 才补齐。Firefox 到现在还在实现中,默认不开启。

也就是说,如果你今天直接上线,Firefox 用户看到的会是:图片完全不显示(因为 opacity: 0 是写在 keyframes 的 from 里,动画不跑,元素就停在那个初始态)。这是个很严重的翻车方式。

所以务必留一层保护。最省事的是用 @supports 把整套动画包起来,默认样式写成”已经是最终状态”:

.article img {
  opacity: 1;
  transform: none;
}

@supports (animation-timeline: view()) {
  .article img {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 60%;
  }
}

这样不支持的时候,图片就是普通地正常显示,功能一点不丢。支持的时候才享受那个渐显效果。

如果渐显本身是产品的核心体验,那还得补一段 IntersectionObserver 的 fallback。写起来也不长,核心思路是同样的效果,只是由观察器来加类名:

if (!CSS.supports('animation-timeline', 'view()')) {
  const io = new IntersectionObserver(
    (entries) => {
      for (const e of entries) {
        if (e.isIntersecting) {
          e.target.classList.add('is-revealed');
          io.unobserve(e.target);
        }
      }
    },
    { rootMargin: '0px 0px -15% 0px' }
  );
  document.querySelectorAll('.article img').forEach(el => io.observe(el));
}

然后给 .is-revealed 写一个普通的 CSS transition 就完事了。两套机制互不干扰。

顺带一提,prefers-reduced-motion 也得处理。虽然浏览器不会帮你自动关掉滚动驱动动画(它不像跨文档视图过渡那样自带这个逻辑),但你该自己加:

@media (prefers-reduced-motion: reduce) {
  * {
    animation-timeline: auto !important;
  }
}

把时间轴改回 auto,动画就会退回成基于时长的普通动画。因为大多数这类动画的时长设置本来就没写,实际效果就是动画几乎不体现,元素直接停在最终状态。这个降级算干净。

调试的时候怎么下手

这块的调试体验做得不错。Chrome DevTools 里打开 Animations 面板,页面上每个滚动驱动动画都会列出来,点进去你能手动拖动进度滑块,直接看某个滚动位置对应的动画状态。

更实用的是,在 Elements 面板里选中那个元素,右侧 Styles 里 animation-timeline 那一行会显示一个小的”滚动条图标”,点开能看到时间轴的分区示意图——entrycontainexit 这几段用不同颜色标出来。当你搞不清 animation-range 到底落在了哪一段,这个图能省很多时间。

还有一个技巧:临时给容器加个 outline: 1px solid red,滚动的时候你就知道动画到底跟的是哪个容器了。用错滚动容器的坑我踩过不止一次,尤其是页面上同时存在 html 滚动和某个局部容器滚动的时候。

几个必踩的坑

动画元素不能有 display: none 的祖先。 元素不渲染的话,它就没有几何信息,view() 时间轴算不出来,进度一直是 0。一个 tab 面板里藏着的内容切过去之后动画不对,多半是这个原因。

content-visibility: auto 要小心。 它会让视口外的内容跳过渲染,虽然能省性能,但和 view() 时间轴配在一起的时候,几何计算可能不准。两个特性都想要的话,测试的时候多滚几遍看看。

别指望 animation-fill-mode 帮你兜底。 我一开始以为只在支持的时候才写动画、不支持就不写就行,但 keyframes 里的初始值依然会在不支持的环境里生效(如果动画属性被部分解析的话)。最稳的还是把最终状态写在基础样式里,动画只负责”从某个偏移值过渡过来”。

时间轴名字和 CSS 自定义属性会打架。 如果你给一个容器同时写了 --deck: somethingscroll-timeline-name: --deck,那是两个命名空间,不会冲突,但读代码的人会非常困惑。命名上建议分开,比如时间轴统一用 --t- 前缀。

滚动容器得有实际可滚动的距离。 如果内容刚好铺满,没有溢出,滚动范围是 0,整条时间轴就退化成一个点,动画永远卡在同一帧。这个在响应式布局里特别容易碰到——大屏幕上不溢出,动画就”失灵”了。写的时候想一想内容不足时的表现。

最后说两句

滚动驱动动画最打动我的地方,其实不是”省了多少行 JS”,而是它把一件原本含糊不清的事情表达清楚了。

以前写滚动效果,代码是这样描述的:”监听到滚动事件 → 算一下位置 → 手动设置样式”。你读代码的时候,得在脑子里把这三步还原成一个连续的过程,才能明白最终效果是什么。

现在是这样描述的:”这个动画的时间轴是页面滚动,生效区间是前 120 像素”。声明式的表达把”意图”直接写出来了,效果是什么样,读一眼就知道。

而且因为它跑在合成器上,不会因为主线程忙就卡顿,也不需要在滚动里做节流那一堆取舍。如果你的项目里现在还挂着一堆 scroll 监听,值得挑一个场景换成这套写法试试,哪怕只是先做那个进度条。

CSS 滚动驱动动画实战:用 scroll() 和 view() 做阅读进度条、入场渐显与目录高亮
收藏 (0) 打赏

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

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

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

淘吗网 html CSS 滚动驱动动画实战:用 scroll() 和 view() 做阅读进度条、入场渐显与目录高亮 https://www.taomawang.com/web/html/2754.html

常见问题

相关文章

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

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