返回目录

从补间到叙事: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();
});