滚动触发动画怎么用纯CSS搞定,为什么比JS更香?

3,688字
16–23 分钟
in

滚动触发动画这事儿,前端圈子里早就玩得飞起,比如那种页面滑到某个位置,元素就“咻”一下弹出来,或者整组内容像走秀一样挨个登场。以前大部分得靠JavaScript监听滚动事件,性能容易翻车,而且代码一多就头大。现在Chromium内核的浏览器(Chrome、Edge等)已经原生支持滚动驱动动画,但注意咯,滚动触发动画(scroll-triggered)跟滚动驱动动画(scroll-driven)不是一回事儿。前者就像踩到地雷——滑过某个点就触发一次完整动画,不回头;后者是跟着滚动进度走,滑回去动画也倒着放。今天这篇就唠唠,怎么纯靠CSS整出那种“滑到就触发、触发就定死”的骚操作,顺便甩一个叫Web-Slinger.css的现成方案。

目录

概念拆解

滚动触发动画的核心,是让页面滚动到某个预设的“坎儿”时,某个元素直接执行一段动画,而且执行完就保持最终状态,不会因为往回滚就缩回去。这跟Tinder上右划匹配一个道理——划过去就配对成功,不会因为你划回来就取消。要实现这效果,目前官方CSS还没给标准属性,但社区有大神搞出了组合拳:animation-timeline: view() 搭配自定义属性(--var)和样式查询(style queries)。view() 这个函数能让动画基于元素进入视口的进度来跑,但默认是双向的。怎么变成单向?操作是让触发元素一进视口就“粘”在顶部,同时改写自定义属性的值,其他元素通过样式查询感知到变化后,执行一次性动画。整个过程不碰JS,纯CSS内卷出来的黑科技。

对比项滚动驱动动画滚动触发动画
播放方向双向可逆单向一次性
触发方式随滚动进度过阈值即触发
典型场景视差滚动条元素入场秀

纯CSS方案

这套玩法的祖师爷是Ryan Mulligan,他把animation-timeline、自定义属性和样式查询揉在一起,实现了滚动触发的闭环。下面直接上两个实战方案,照着抄就能跑。

方案一 库引用

Web-Slinger.css 是个现成的玩具库,模仿Wow.js的用法但纯CSS实现。第一步,把库文件拉进来(假设已经托管到某个地方),然后在HTML里写一个空触发块和要动画的目标元素。

<div class="scroll-trigger-8"></div>
<img class="on-scroll-trigger-8 animate__animated animate__flipInY" src="奶牛图片.jpg" alt="奶牛">

这里的数字8是触发编号,scroll-trigger-8 这个空div就是地雷。当页面往下滑,这个div刚露出头的那一刹那,它就会自动固定到视口最顶部,同时把自定义属性 --scroll-trigger-8 的值从0变成1。而图片上的 on-scroll-trigger-8 类,配合样式查询 style(--scroll-trigger-8: 1),会立刻把 animate__flipInY 动画播放一遍。动画跑完就停,哪怕再往上滑,图片也不会翻回去。注意,每个触发编号全局唯一,多个元素可以用同一个编号,实现“一个雷炸一片”的效果。

如果想把多个触发累积起来做点数学运算,比如统计有几只牛入场了,可以这样写:

.cownter::after {
  --cownter: calc(var(--scroll-trigger-2) + var(--scroll-trigger-4));
  counter-set: cownter var(--cownter);
  content: "已看到 " counter(cownter) " 头牛";
}

这里 --scroll-trigger-2--scroll-trigger-4 各自在对应触发块出现时变成1,加起来就是总数。这波操作甚至能解决英文复数问题,比如1头牛显示“cow”,多显示“cows”,一个样式查询搞定。写代码时记得给触发块设置足够的间距,别让两个触发块同时进入视口,否则数值会跳变。另外动画持续时间必须设成极小的值(比如1ms),因为animation-duration 不能为0,但1ms几乎瞬移,用户感知不到延迟。

方案二 手撸内核

不想依赖外部库的话,可以直接用SCSS搓一套迷你版。核心思路分三步:造一个会粘顶的触发器,定义一个全局可读的时间线,再用样式查询触发动画。

第一步,写一个通用类 scroll-trigger,给它绑定 view() 时间线和一段关键帧。

.scroll-trigger {
  animation-timeline: view();
  animation-name: stick-to-top;
  animation-fill-mode: both;
  animation-duration: 1ms;
}

@keyframes stick-to-top {
  0.1%, 100% {
    position: fixed;
    top: 0;
    z-index: -1;
  }
}

注意关键帧里不能从0%开始,因为0%时元素还没完全进入视口,会闪烁。从0.1%开始固定,体感上就是“一进来就吸顶”。z-index: -1 是为了让这个触发器不可见,避免遮挡内容。

第二步,给触发器起个名字,让全局能引用它。

<body style="timeline-scope: --my-trigger">
  <div class="scroll-trigger" style="view-timeline-name: --my-trigger"></div>
  <!-- 其他内容 -->
</body>

timeline-scope 属性让body元素能识别到触发器的时间线名称,这样页面任何地方都能用 animation-timeline: --my-trigger 来同步滚动进度。但是直接同步会变成双向驱动,所以需要第三步来“单向化”。

第三步,利用自定义属性做单向开关。在触发器的 stick-to-top 动画里,顺便改一个自定义属性值。

@keyframes stick-to-top {
  0.1%, 100% {
    position: fixed;
    top: 0;
    --triggered: 1;
  }
}

然后在要动画的元素外面包一层容器,用样式查询监听 --triggered 的值。

@container style(--triggered: 1) {
  .target {
    animation: bounceIn 0.6s forwards;
  }
}

注意这里 forwards 让动画保持结束状态。整个流程的坑点在于,@container style() 查询需要容器本身建立包含上下文,所以目标元素的父级要加 container-type: inline-sizestyle。另外浏览器的支持度:Chromium 115+ 完全支持,Firefox需要开启 layout.css.container-queries.style-contain.enabled 标志,Safari还在路上。测试的时候可以用CodePen类环境快速验证。

这个手撸方案能塞进任何项目,只要不超过95行SCSS就能生成全部编号的触发器。但如果需要十几个触发点,编译后的CSS体积会膨胀,每个编号都得单独定义一组规则。这时候可以考虑用CSS自定义属性做动态编号,比如 --trigger-id,配合 animation-timeline: view(--trigger-id) 的写法,不过目前兼容性更差,暂不推荐。老老实实写 scroll-trigger-1scroll-trigger-n 最稳。

进阶骚操作

单个触发只能动一个元素太浪费了,其实一个触发器可以指挥全页面任何位置的元素。比如页面顶部做一个计数器,当滚动到底部最后一个触发器时,显示一个“重新开始”按钮。

<div class="header">
  <h2 class="cownter"></h2>
  <div class="reset-btn on-scroll-trigger-12">
    <a href="#" class="reset">🔁 再来一次</a>
  </div>
</div>

这个 .reset-btn 平时隐藏,只有 --scroll-trigger-12 变成1时才显示。而“再来一次”的点击逻辑更野——通过 :has(.reset:active) 选择器,把页面上所有动画名称重置为 none

:root:has(.reset:active) * {
  animation-name: none;
}

注意这招会杀死所有动画,所以点击后需要刷新页面或重新触发滚动才能恢复。实际产品里可以配合JS局部重置,但纯CSS能做到这一步已经是绝绝子了。写这种代码时务必注意 :has 的性能开销,别在一个包含上千元素的页面滥用。

未来趋势

Chrome团队已经在研究原生的滚动触发API,将来可能直接用 scroll-trigger 属性或者 animation-trigger 搞定,不再需要这些偏方。但目前最好的方案就是上面两套,一个拿来即用,一个深度定制。手头有项目需要滚动入场特效的,赶紧把Web-Slinger.css拿去改吧,记得改里面的奶牛图片换成自己的内容,计数器文案也可以随便调。玩得开心,别把页面整得太卡就成。