滚动触发动画这事儿,前端圈子里早就玩得飞起,比如那种页面滑到某个位置,元素就“咻”一下弹出来,或者整组内容像走秀一样挨个登场。以前大部分得靠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-size 或 style。另外浏览器的支持度: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-1 到 scroll-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拿去改吧,记得改里面的奶牛图片换成自己的内容,计数器文案也可以随便调。玩得开心,别把页面整得太卡就成。
