告别 scroll 事件:用 CSS 滚动时间轴做进度条、渐显和视差

2026-10-16 0 740

一个 scroll 监听、一层 rAF 节流、一次 getBoundingClientRect,再补上 resize 和横竖屏切换的处理——这套代码在过去七八年里被写了无数遍。麻烦的地方不在于难,而在于它永远长得差不多,却永远要重写一遍,而且每次都要重新想一遍节流阈值该给多少。

现在浏览器把”滚动进度”直接做成了 CSS 能读取的东西。动画的推进不再是时间在推,而是滚动位置在推,计算过程挪到了合成阶段,不用 JS 插手。

滚动时间轴到底替掉了什么

先说清楚它替代的是哪部分工作。传统写法大致是这样:滚动事件触发,拿到 scrollTop,除以可滚动高度得到 0 到 1 之间的进度,再把这个进度换算成 transform 或者透明度写回元素。中间有几个绕不开的问题:

  • 在滚动回调里读 getBoundingClientRect 或 offsetTop,会强制浏览器做一次同步布局,滚动帧率很容易掉
  • 移动端滚动事件的触发频率和滚动本身不同步,跟手感靠代码补不回来
  • 多个效果叠加时,谁先算谁后算、状态怎么存,代码会迅速变脏

滚动驱动动画的思路是把”进度”这个概念原生地交给浏览器:你只描述从 A 状态到 B 状态,浏览器自己去问”现在滚到哪了”。关键属性是 animation-timeline,它把动画的时间来源从时钟换成了滚动位置。

需要记住一条:一旦 animation-timeline 生效,animation-duration 就被忽略。你写的 0.6s 不会起作用,进度完全由滚动决定。这一点在写降级方案时会反复用到。

两种时间轴,先分清楚

scroll():跟着某个滚动容器走

.el {
  animation-timeline: scroll(root block);
}

scroll() 接受两个参数,第一个是滚动容器,可选 nearest(默认,最近的可滚动祖先)、root(根滚动容器)、self;第二个是轴,一般用 block,需要横向进度就写 inline 或 x。

这类时间轴的进度只跟滚动位置有关,跟你自己的元素在哪无关。适合做全局性的东西,比如文章阅读进度、顶部导航栏的收缩。

view():跟着元素自己在视口里的位置走

.el {
  animation-timeline: view();
}

view() 的进度是”这个元素相对滚动视口走到哪了”。默认覆盖范围是 cover,也就是从元素刚碰到视口边缘开始,到元素完全离开视口结束。适合做入场、离场这类跟元素自身位置强相关的效果。

案例一:顶部阅读进度条

结构简单到只有一行:

<div class="reading-progress" role="progressbar" aria-label="阅读进度"></div>
.reading-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 3px;
  background: linear-gradient(90deg, #ff5f6d, #ffc371);
  transform-origin: 0 50%;
  scale: 0 1;

  animation: reading-progress linear both;
  animation-timeline: scroll(root block);
}

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

几个选择都是有意为之:

用 scale 而不是 width,因为 width 每变一帧就要重新布局一次,而 scale 只走合成,滚动再快也不会掉帧。scale 的两个值是 x 和 y,所以 scale: 0 1 表示横向压扁为零、纵向保持原样。transform-origin 设成左中,进度条才会从左往右长,不然它从中心往两头撑开。

animation 简写里的 both 不能省。没有了它,动画在时间轴两端之外会回到元素的初始状态,页面刚打开还没滚的时候进度条会闪一下。

案例二:卡片从下方淡入

列表页里最常见的那种效果——卡片进入视口时往上浮一点、透明度从 0 到 1。用 view() 时间轴基本是几行的事。

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

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

这里的新面孔是 animation-range。view() 时间轴提供四个有名字的范围:

  • cover:元素开始接触视口,到完全离开视口
  • entry:元素开始接触视口,到完全进入视口
  • exit:元素开始离开视口,到完全离开视口
  • contain:元素完全进入视口,到开始离开视口

animation-range 要写两个位置:动画的起点和终点。entry 10% entry 60% 的意思是,从”元素刚进入视口 10%”开始播,播到”进入视口 60%”结束。剩下的 40% 路程元素已经完全不透明了,观感上就是很自然地在进入过程中完成淡入,而不是等到完全进来才动。

有个容易混淆的点:animation-range 里的百分比是相对这个范围自己的长度算的,不是相对整条时间轴。所以 entry 50% 和 cover 50% 完全是两个位置。

另外,卡片如果正好在首屏里、页面加载时就已经完整可见,那它的 entry 进度直接就是 100%,早就超过了 60%,动画稳定停在终态。结果是卡片老老实实显示在那儿,不会闪、不会抽搐。这个行为是对的,不用担心首屏元素需要特殊处理。

案例三:Hero 区域的视差

视差的原理说穿了很朴素:图片比它的容器高,容器裁掉多出来的部分,滚动时让图片在余量范围内上下移动,看起来就有了纵深。

<div class="hero-visual">
  <img src="hero.jpg" alt="">
</div>
.hero-visual {
  height: 70vh;
  overflow: hidden;
}

.hero-visual img {
  display: block;
  width: 100%;
  height: 130%;
  object-fit: cover;

  animation: hero-parallax linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

@keyframes hero-parallax {
  from { translate: 0 -10%; }
  to   { translate: 0 10%; }
}

图片高度给到容器的 130%,多出来的 30% 就是留给位移的余量。translate 里的百分比是相对元素自身高度算的,130% 的 10% 约等于容器高度的 13%,上下各走 13%,加起来 26%,刚好塞进 30% 的余量里,两边都不会露白。

object-fit: cover 不能省。少了它,图片会被硬拉到 130% 的高度,人脸直接变形。

animation-range 这里用了 cover 0% cover 100%,也就是整个可见周期内位移都在发生,节奏最连续。如果想让它只在元素占据视口主体时移动,换成 contain 0% contain 100% 会更克制一些。

多条动画共用一条时间轴

如果页面上好几个元素都要跟着同一个滚动容器走,给每个元素都写 scroll() 也能跑,但更清晰的做法是先把时间轴命名出来。

.page {
  scroll-timeline-name: --page;
  scroll-timeline-axis: block;
}

.page__header {
  animation: header-shrink linear both;
  animation-timeline: --page;
}

命名时间轴默认只在本容器的后代里可见。如果引用方不在这个容器的后代链上(比如它是滚动容器的兄弟节点),就需要用 timeline-scope 把这个名字提升到共同祖先上,否则引用不到,动画会静默失效——不会报错,只是不动,这一点排查起来有点费时间。

降级和减动效

把动画包在 @supports 里是最省心的降级方式。思路是:基础样式写成元素正常可见的样子,只在支持滚动时间轴时再挂动画。

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

不支持的时候,.card 就只是一个普通的、可见的卡片。内容完整,没有半透明的鬼影。

减动效处理要单独照顾进度条:

@media (prefers-reduced-motion: reduce) {
  .reading-progress { display: none; }
  .card { animation: none; opacity: 1; translate: none; }
  .hero-visual img { animation: none; translate: none; }
}

.card 和图片本身没有隐藏问题,它们的初始状态就是正常可见的,把动画撤掉就回到静态。进度条不一样,它的基础样式里写着 scale: 0 1,单纯撤掉动画只会让它消失——直接 display: none 反而更合理,一个恒定在动的进度条对前庭敏感的用户本来就不友好。

这段媒体查询要放在文件靠后的位置,和 @supports 里的规则同优先级,靠书写顺序来覆盖。

几个真实会踩到的坑

给固定定位元素加 view() 没意义。position: fixed 的元素相对视口的位置永远不变,view() 时间轴的进度从头到尾都是同一个值,动画等于没写。这类元素要用 scroll()。

overflow: hidden 的容器做不了 scroll() 时间轴。这种容器在规范意义上仍然是可滚动的溢出区域,程序化设置 scrollTop 依然有效,但用户没法用滚轮或手势驱动它。结果就是时间轴进度永远是 0,元素一动不动,而且没有任何报错。

动画属性碰了布局属性,性能优势就没了。滚动驱动动画跑在合成器上是有前提的——只碰 transform、opacity、translate、scale、rotate、filter 这些。一旦动到 width、height、top、margin,每一帧都要重新布局,反而比手写 JS 还难控制,因为你现在没法用节流去救它。

别给 animation-timeline 加 will-change。浏览器已经知道这些元素在参与动画,额外声明只会多占一层合成层的内存。

上线前的检查清单

  1. 动画只碰合成友好的属性,一次都没碰 width / top / margin
  2. 动画规则包在 @supports 里,不支持时页面内容照常可见
  3. @media (prefers-reduced-motion: reduce) 有对应处理,进度条这类纯装饰动画直接隐藏
  4. 首屏内的元素单独看一眼,确认初始进度落在终态而不是中间值
  5. 在 DevTools 的 Animations 面板里逐个确认时间轴名字绑定成功,尤其是用了 timeline-scope 的地方

写这篇文章的时候,Chromium 和 Safari 已经默认支持滚动驱动动画,Firefox 的节奏慢一些。所以 @supports 不是可选项,它是这套写法的必备部分。但只要包好,剩下的代码确实比原来短了一大截。

这套 CSS 能替掉的,是那些”读滚动位置、算比例、改样式”的机械劳动。真正需要 JS 的滚动逻辑还在——比如根据滚动位置改变 DOM 结构、驱动 canvas 或 WebGL。但至少进度条和入场渐显这两件事,可以彻底从 JS 文件里删掉了。

告别 scroll 事件:用 CSS 滚动时间轴做进度条、渐显和视差
收藏 (0) 打赏

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

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

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

淘吗网 css 告别 scroll 事件:用 CSS 滚动时间轴做进度条、渐显和视差 https://www.taomawang.com/web/css/2949.html

常见问题

相关文章

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

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