从补间到叙事:GSAP 动画实战 · 公开阅读
第5章:ScrollTrigger
在独立滚动区中比较直接跟随与平滑追赶,掌握滚动动画。
本章小节
5.1 ScrollTrigger 是什么
你用遥控器快进一部电影——手指拖到哪,画面就跳到哪。ScrollTrigger 让网页动画拥有同样的行为:滚动进度直接驱动动画进度,用户的每一次滚动都在"快进"或"快退"动画。
ScrollTrigger 是 GSAP 的杀手级插件,也是它从"动画库"跃升为"交互动效引擎"的关键一步。用之前先注册:
gsap.registerPlugin(ScrollTrigger);5.2 基本用法:三个核心参数
trigger、start、end 构成了 ScrollTrigger 的最小可用配置——三个参数就能跑起来:
gsap.to(".box", {
x: 500,
scrollTrigger: {
trigger: ".box", // 触发器元素
start: "top center", // 动画开始位置
end: "bottom top", // 动画结束位置
}
});start 与 end 参数
- start:动画开始条件——当元素边缘和视口边缘满足指定位置关系时触发
- end:动画结束条件——当位置关系不再满足时结束
语法格式永远是 "元素位置 视口位置"(元素在前,视口在后):
| 写法 | 含义 |
|---|---|
"top center" | 元素顶部到达视口中心 |
"bottom bottom" | 元素底部到达视口底部 |
"top top+=100" | 元素顶部到达视口顶部再往下 100px |
"top 80%" | 元素顶部到达视口 80% 位置 |
end 参数默认 "bottom top"(元素底部离开视口顶部),start 默认 "top bottom"(元素顶部刚进入视口底部)。你不写也能用,但最好还是显式写上——看着清楚。
markers——可视化触发区域
ScrollTrigger 的 start/end 是抽象的位置关系,出了问题很难排查。开启 markers: true 可以在页面上渲染两条彩色标记线,start 和 end 的位置一目了然:
scrollTrigger: {
trigger: ".box",
start: "top center",
end: "bottom top",
markers: true, // 在页面上显示 start/end 标记
}调好参数后再把 markers 关掉即可。
5.3 scrub 模式——像拖视频进度条一样
基础 scrub:滚动到哪,动画到哪
scrub 将滚动位置映射为动画进度——你滚动了 30% 的距离,动画就前进到 30%。和拖视频进度条原理完全一致。
gsap.to(".box", {
x: 800,
scrollTrigger: {
trigger: ".box",
start: "top center",
end: "bottom top",
scrub: true, // 动画进度 = 滚动进度
}
});scrub: true 将滚动区间内的位置直接映射为动画进度,不添加追赶延迟。它与 scrub: 1 不同:后者会用约一秒平滑追赶滚动进度。映射的是进度比例,并不是一个滚动像素对应一帧。
scrub 数值:让动画"慵懒"一点
有时候你不想让动画跟得那么紧——滚动停下来了,动画还想再"滑"一小段。给 scrub 一个大于 0 的数字就行:
scrub: 0.5 // 动画滞后 0.5 秒跟上滚动
scrub: 2 // 滞后 2 秒,更平滑| scrub 值 | 效果 |
|---|---|
true | 直接跟随滚动进度,不添加追赶延迟 |
0.5 | 快速跟随,带轻微惯性 |
1 | 用约一秒平滑追赶滚动进度 |
2 | 明显滞后,平滑缓冲 |
数值越大,动画跟随越滞后——滚动停止后动画还会继续运行一小段。
这里的数值是平滑追赶时间,不是固定延迟,也不是动画的 duration。详见 ScrollTrigger 的 scrub 参数。
对比示例
// 硬绑定:精确跟手,适合进度条、滚动条式动画
gsap.to(".progress-bar", {
scaleX: 1,
scrollTrigger: { trigger: ".container", start: "top top", end: "bottom bottom", scrub: true }
});
// 平滑跟随:适合视差、背景位移
gsap.to(".bg-layer", {
y: -200,
scrollTrigger: { trigger: ".hero", start: "top top", end: "bottom top", scrub: 1.5 }
});把这一节跑一遍:滚动进度与 scrub
滚动位置先映射到 0…1 的时间线进度,再由 Tween 的 ease 决定属性值。为了使空间运动与滚动距离一致,本实验用 none。数值 scrub 则增加追赶时间,并非另一个持续 delay。
实验:滚动进度与 scrub
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:在演示框内滚到一半,比较 scrub:true 与 scrub:1。暂停后检查位置是否稳定;页面正文滚动与此实验视口互不替代。
5.4 pin 模式——把元素"钉"在墙上
pin 的效果就像用图钉把一张纸钉在墙上——纸张不动,后面的东西继续往下滑。
ScrollTrigger.create({
trigger: ".pin-me",
start: "top top",
end: "+=500", // 固定 500px 的滚动距离
pin: true,
pinSpacing: true, // 默认开启,自动推后续内容留出空白
});pin 嵌套动画——一个"滚动故事"
pin 真正的威力在于把整个动画序列绑在固定时间段内播放。用户不是在滚动一个元素,而是在滚动观看一段叙事:
gsap.timeline({
scrollTrigger: {
trigger: ".section",
start: "top top",
end: "+=2000",
pin: true,
scrub: true,
}
})
.to(".section .text", { opacity: 1, y: -50 })
.to(".section .img", { scale: 1, rotation: 0 }, "-=0.5")
.to(".section .overlay", { opacity: 0.6 }, "<");通过 pin + timeline 的组合,可以将滚动区域变成分段的动画叙事——每个段落是一段独立的滚动播放片段。
pin 的 end 值写法
end: "+=500" // 固定 500px 滚动距离
end: "bottom top" // 固定直到元素底部碰到视口顶部
end: "max" // 固定到可滚动的最大位置"max" 是常用安全值——它能确保内容完整展示,不会因为计算偏差导致 pin 提前结束,导致后面的内容被"吃掉"。
把这一节跑一遍:固定区块与占位
本实验有明确的 start、end 和独立 scroller。pin-spacer 用于测量与占位;pinSpacing:false 不取消 pin,但会改变后续内容空间。滚动读数显示实际像素边界,避免只看配置字符串。
实验:固定区块与占位
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:比较 true/false 的滚动末端和内容距离。在 start 到 end 内观察固定状态,到 end 后观察解除;不要为默认占位重复增加 600px 空白。
5.5 toggleActions——四个开关组合
当动画不是 scrub 模式而是独立播放时,你需要控制它在四个关键时刻的行为。toggleActions 通过四个值分别指定:
scrollTrigger: {
trigger: ".box",
toggleActions: "play pause resume reset",
// ① ② ③ ④
}四个位置分别对应:
| 位置 | 触发时机 | 可选值 |
|---|---|---|
| ① onEnter | 滚进 start 触发线 | play pause resume reset restart complete reverse none |
| ② onLeave | 滚出 end 触发线 | 同上 |
| ③ onEnterBack | 反向滚进触发线 | 同上 |
| ④ onLeaveBack | 反向滚出触发线 | 同上 |
四个值依次对应:进入 start 触发线时的动作、离开 end 触发线时的动作、反向进入触发线时的动作、反向离开触发线时的动作。
典型组合
// 元素进入视口播放,离开时暂停,回去时恢复
toggleActions: "play none none pause"
// 元素每次进入都重新播放
toggleActions: "restart none none reset"
// 进入播放,离开反向播放
toggleActions: "play none reverse none"把这一节跑一遍:四个边界事件
四个动作按向下进入、向下离开、向上进入、向上离开的顺序对应边界事件。这里不是 scrub:进入后动画按自己的时间播放,滚动只触发动作。
实验:四个边界事件
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:向下、向上各经过整个区块,看事件读数和播放头。比较两套 toggleActions,确认 reset 与 reverse 的区别。
5.6 scroller 与 containerAnimation——两种不同的滚动场景
如果滚动发生在独立容器内(比如弹窗内容区、侧边栏),使用 scroller 指定真正有滚动条的元素:
gsap.to(".inner-box", {
x: 300,
scrollTrigger: {
trigger: ".inner-box",
start: "top 60%",
end: "bottom 40%",
scroller: ".container", // 指定实际的滚动容器
}
});containerAnimation 则用于另一种场景:页面垂直滚动驱动一个容器横向平移,容器里的子元素需要按这种“模拟横向滚动”触发动画。它接收父容器的平移动画,并要求父动画使用 ease: "none"。子触发器不能使用 pin 或 snap。
const horizontalTween = gsap.to(".track", {
x: () => -(document.querySelector(".track").scrollWidth - window.innerWidth),
ease: "none",
scrollTrigger: {
trigger: ".gallery",
pin: true,
scrub: true,
end: () => "+=" + (document.querySelector(".track").scrollWidth - window.innerWidth),
invalidateOnRefresh: true,
},
});
gsap.from(".track .feature", {
opacity: 0,
scrollTrigger: {
trigger: ".track .feature",
containerAnimation: horizontalTween,
start: "left center",
},
});.gallery 是裁切横向轨道的外层,.track 的内容宽度应大于视口;当内容没有横向溢出时,不应创建这段横向动画。
把这一节跑一遍:垂直滚动驱动水平画廊
画廊的位移是 scrollWidth 减去视口宽度,而不是随便填一个百分比。演示实现父级水平移动;需要在各面板上再触发子动画时,使用 containerAnimation,并让父级 ease 为 none。
实验:垂直滚动驱动水平画廊
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:拖动总进度到 0%、50%、100%,核对三个面板。修改窗口宽度后重置,使距离按新尺寸重新测量。
5.7 ScrollTrigger.batch()——批量绑定,一个管一百个
页面里有 100 张卡片,每个都要做滚动入场动画——难道要写 100 个 ScrollTrigger?不用——batch() 一句话搞定:
ScrollTrigger.batch(".card", {
onEnter: (elements, triggers) => {
gsap.to(elements, {
opacity: 1,
y: 0,
stagger: 0.15,
duration: 0.6,
});
},
start: "top 80%",
once: true, // 只触发一次
});batch() 的优势:
- 批量回调:每个目标仍有自己的 ScrollTrigger,
batch()会把短时间内触发的回调合并,便于统一执行 stagger 动画 - 简洁:不用手动
.forEach()创建 - 回调参数:
elements是进入视口的那一批元素数组,可以直接传给 gsap.to
batch 的完整回调
ScrollTrigger.batch(".item", {
onEnter: batch => gsap.to(batch, { autoAlpha: 1, stagger: 0.1 }),
onLeave: batch => gsap.to(batch, { autoAlpha: 0 }),
onEnterBack: batch => gsap.to(batch, { autoAlpha: 1 }),
onLeaveBack: batch => gsap.to(batch, { autoAlpha: 0 }),
start: "top 85%",
once: true,
});5.8 实战示例
示例一:视差滚动背景
gsap.to(".parallax-bg", {
y: () => window.innerHeight * 0.3,
ease: "none",
scrollTrigger: {
trigger: ".hero",
start: "top top",
end: "bottom top",
scrub: 1,
}
});示例二:滚动进度条
gsap.to(".progress-bar", {
scaleX: 1,
transformOrigin: "left center",
ease: "none",
scrollTrigger: {
trigger: "body",
start: "top top",
end: "bottom bottom",
scrub: true,
}
});示例三:逐段 Reveal 内容
document.querySelectorAll(".reveal").forEach((el) => {
gsap.fromTo(el,
{ y: 60, opacity: 0 },
{
y: 0,
opacity: 1,
duration: 0.8,
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 40%",
toggleActions: "play none none reverse",
}
}
);
});示例四:Timeline + ScrollTrigger 段落叙事
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".story-section",
start: "top top",
end: "+=3000",
pin: true,
scrub: true,
markers: false,
}
});
tl.from(".story-title", { opacity: 0, y: 50 })
.from(".story-img-1", { scale: 0.5, opacity: 0 })
.from(".story-text-1", { x: -100, opacity: 0 }, "-=0.3")
.from(".story-img-2", { x: 100, opacity: 0 })
.from(".story-text-2", { opacity: 0, y: 30 }, "-=0.3");5.9 常见陷阱
陷阱一:start / end 理解反了
现象:动画位置不对,总感觉"反了"。
原因:start 的格式是 "元素位置 视口位置",不是 "视口位置 元素位置"。很多新手习惯性地把视口放前面,结果完全反了。
错误理解:start: "center top" → 视口中心到达元素顶部
正确理解:start: "top center" → 元素顶部到达视口中心
↑ 元素的边 ↑ 视口的边口诀:元素在前,视口在后。 不确定时就开 markers: true,两条线一看就明白了。
陷阱二:pin 与 CSS 打架
现象:固定元素跳动、错位、宽度突然变了。
原因:pin 底层用 position: fixed + transform 实现。如果你自己还在 CSS 里给这个元素写 transform、will-change、transition,就跟 GSAP 的内联样式干架了。
解决:
- 别在 pin 目标上用 CSS
transition - 别在 pin 目标上用 CSS
transform - 如果是 flex 或 grid 布局的元素,被 pin 后定位上下文会变,留个心眼
/* 这段 CSS 会让 pin 彻底乱套 */
.pin-target {
transform: translateY(0); /* 冲突! */
transition: all 0.3s; /* 冲突! */
}陷阱三:scrub 时用了不对的属性导致卡顿
现象:滚动时掉帧、幻灯片一样的体验。
原因:scrub 动画在滚动事件中每帧都在算。如果你让动画去改 width、height、top、left 这类属性,浏览器每帧都要重新计算布局——不卡才怪。
解决:只对 transform 和 opacity 做动画。这两个属性只触发"合成"阶段,不需要重新布局和重绘——性能好得多。
// 差:触发 layout
gsap.to(".box", { width: "100%", scrollTrigger: { ..., scrub: true } });
// 好:只触发 composite
gsap.to(".box", { scaleX: 2, transformOrigin: "left center", scrollTrigger: { ..., scrub: true } });陷阱四:Mobile Safari 滚动"不听话"
现象:在 iPhone 上 ScrollTrigger 行为异常——动画不触发、有延迟、跳来跳去。
原因:Mobile Safari 的滚动事件是异步的,而且惯性滚动时事件触发频率不稳定。
解决:
ScrollTrigger.config({
ignoreMobileResize: true, // 忽略地址栏收起/展开
});
ScrollTrigger.normalizeScroll(true); // GSAP 3.12+ 标准化滚动行为如果 normalizeScroll 不可用,确保在合适时机手动刷新:
// 窗口 resize 时刷新所有 ScrollTrigger
window.addEventListener("resize", () => ScrollTrigger.refresh());
// 内容动态加载后刷新
ScrollTrigger.refresh();陷阱五:忘记注册插件
// 报错:ScrollTrigger is not defined
gsap.to(".box", { x: 100, scrollTrigger: { ... } });
// 正确:先注册
gsap.registerPlugin(ScrollTrigger);
gsap.to(".box", { x: 100, scrollTrigger: { ... } });陷阱六:refresh 时机没跟上
动态加载内容后,页面变长了,但 ScrollTrigger 的 start/end 还是按旧页面算的位置——动画就偏移了。
// 错误:加载完内容后 start/end 偏移了
fetchContent().then(() => {
document.querySelector(".container").innerHTML = newHTML;
// start/end 还是旧值!
});
// 正确:加载后刷新
fetchContent().then(() => {
document.querySelector(".container").innerHTML = newHTML;
ScrollTrigger.refresh();
});