OpenMontage 中的 GSAP ScrollTrigger 实战:滚动驱动动画、Pinning、Scrub 与容器动画的官方技能全解
本篇技术指南围绕 OpenMontage 仓库内置的
gsap-scrolltrigger技能文档(.agents/skills/gsap-scrolltrigger/SKILL.md)展开,系统讲解 GSAP 官方 ScrollTrigger 插件的注册、触发机制、核心配置、批处理、滚动代理、Scrub、Pinning 与横向伪滚动等关键技术点。作为本文读者的你,将在理解该技能文件在 OpenMontage 技能体系中的定位之后,掌握一套可直接复用的滚动驱动 Web 动效实现方案与避坑清单。
一、技能定位:ScrollTrigger 在 OpenMontage 中的角色
OpenMontage 是一个 Agent 驱动的视频制作系统,仓库内以 .agents/skills/ 方式维护了数百个"技能(Skill)"文件,供接入的 AI 编程助手在写代码时自动检索与遵循。GSAP(GreenSock Animation Platform)相关能力被拆分为八个可加载的 Layer-3 技能,分别为 gsap-core、gsap-timeline、gsap-react、gsap-plugins、gsap-performance、gsap-utils、gsap-frameworks 与本文主角 gsap-scrolltrigger,它们统一由 .agents/skills/gsap/README.md 这一导航文件编排(该目录本身不含 SKILL.md,不会被直接加载)。
gsap-scrolltrigger 技能文件的 frontmatter(SKILL.md)声明了它的触发语义:
name: gsap-scrolltrigger
description: Official GSAP skill for ScrollTrigger — scroll-linked animations, pinning, scrub, triggers. Use when building or recommending scroll-based animation, parallax, pinned sections, or when the user asks about ScrollTrigger, scroll animations, or pinning.
license: MIT
该技能来自 greensock 官方技能仓库,MIT 许可。需要特别强调的是,按 .agents/skills/gsap/README.md 的说明,OpenMontage 当前的视频成片主要由 Remotion 的 useCurrentFrame() + interpolate() + spring() 或 HyperFrames 运行时驱动;而 ScrollTrigger 属于**滚动驱动(scroll-driven)**能力,其适用边界是 "web preview only, not video render"——即用于交互式网页预览中的滚动动效,而不是离线视频帧渲染。把握这一边界,是正确使用本技能的第一前提。
本技能与其它 GSAP 技能的协作关系(原文即列明):
- 编写 tween/timeline 本体 → 看 gsap-core 与 gsap-timeline;
- React 组件内的注册与清理 → 看 gsap-react;
- ScrollSmoother / scroll-to 平滑滚动能力 → 看 gsap-plugins。
下文所有配置与代码均以技能文件正文为骨架,并结合仓库说明展开注释。
二、注册插件:一次注册,全局生效
ScrollTrigger 是 GSAP 的官方插件,脚本加载完成后必须先注册,且全局只需注册一次:
gsap.registerPlugin(ScrollTrigger);
注册必须在任何使用 scrollTrigger 配置或 ScrollTrigger.* 静态方法的代码之前执行。该文件还给出了编写代码时的顶层约束(SKILL.md):无论有多少个触发器,都不要重复注册。
三、基本触发器:把 tween 绑定到滚动位置
最基础的用法是把一个 tween 或 timeline 与滚动位置绑定。以下来自技能文档的示例中,.box 从视口顶部进入视口中心时开始向右移动 500px,离开视口中心时反向移动回来:
gsap.to(".box", {
x: 500,
duration: 1,
scrollTrigger: {
trigger: ".box",
start: "top center", // when top of trigger hits center of viewport
end: "bottom center", // when the bottom of the trigger hits the center of the viewport
toggleActions: "play reverse play reverse" // onEnter play, onLeave reverse, onEnterBack play, onLeaveBack reverse
}
});
start / end 的取值语法
start / end 描述"触发元素(trigger)位置 vs 视口/滚动容器位置",格式为 "triggerPosition viewportPosition":
- 字符串组合示例:
"top top"、"center center"、"bottom 80%"; - 纯数字像素值:如
500,表示滚动容器(默认视口)从顶部算起总共滚过 500px 时触发; - 相对值:
"+=300"(超出 start 再滚 300px)、"+=100%"(超出 start 一个滚动容器高度)、"max"(最大滚动位置); clamp()包裹(v3.12+):将位置限制在页面边界内,如start: "clamp(top bottom)"、end: "clamp(bottom top)";- 函数:返回字符串或数字,接收当前 ScrollTrigger 实例,可用于动态计算。
当布局发生变化(如动态内容、字体加载、图片加载导致尺寸/位置变化)后,需要调用 ScrollTrigger.refresh() 让这些值重新计算。
四、核心配置项全表
技能文档为 scrollTrigger 配置对象给出下表(SKILL.md),这是整份技能最可复用的速查表,建议写作时逐项对照:
| Property | Type | Description |
|---|---|---|
| trigger | String | Element | Element whose position defines where the ScrollTrigger starts. Required (or use shorthand). |
| start | String | Number | Function | When the trigger becomes active. Default "top bottom" (or "top top" if pin: true). |
| end | String | Number | Function | When the trigger ends. Default "bottom top". Use endTrigger if end is based on a different element. |
| endTrigger | String | Element | Element used for end when different from trigger. |
| scrub | Boolean | Number | Link animation progress to scroll. true = direct; number = seconds for playhead to "catch up". |
| toggleActions | String | Four actions in order: onEnter, onLeave, onEnterBack, onLeaveBack. Each: "play", "pause", "resume", "reset", "restart", "complete", "reverse", "none". Default "play none none none". |
| pin | Boolean | String | Element | Pin an element while active. true = pin the trigger. Don't animate the pinned element itself; animate children. |
| pinSpacing | Boolean | String | Default true (adds spacer so layout doesn't collapse). false or "margin". |
| horizontal | Boolean | true for horizontal scrolling. |
| scroller | String | Element | Scroll container (default: viewport). Use selector or element for a scrollable div. |
| markers | Boolean | Object | true for dev markers; or { startColor, endColor, fontSize, ... }. Remove in production. |
| once | Boolean | If true, kills the ScrollTrigger after end is reached once (animation keeps running). |
| id | String | Unique id for ScrollTrigger.getById(id). |
| refreshPriority | Number | Lower = refreshed first. Use when creating ScrollTriggers in non–top-to-bottom order: set so triggers refresh in page order (first on page = lower number). |
| toggleClass | String | Object | Add/remove class when active. String = on trigger; or { targets: ".x", className: "active" }. |
| snap | Number | Array | Function | "labels" | Object | Snap to progress values. Number = increments (e.g. 0.25); array = specific values; "labels" = timeline labels; object: { snapTo: 0.25, duration: 0.3, delay: 0.1, ease: "power1.inOut" }. |
| containerAnimation | Tween | Timeline | For "fake" horizontal scroll: the timeline/tween that moves content horizontally. ScrollTrigger ties vertical scroll to this animation's progress. See Horizontal scroll (containerAnimation) below. Pinning and snapping are not available on containerAnimation-based ScrollTriggers. |
| onEnter, onLeave, onEnterBack, onLeaveBack | Function | Callbacks when crossing start/end; receive the ScrollTrigger instance (progress, direction, isActive, getVelocity()). |
| onUpdate, onToggle, onRefresh, onScrubComplete | Function | onUpdate fires when progress changes; onToggle when active flips; onRefresh after recalc; onScrubComplete when numeric scrub finishes. |
补充两个快捷语义:
- 简写形式:
scrollTrigger: ".selector"等价于只设置了trigger一项。 refreshPriority的取值方向:数字越小越先 refresh,页面顶部第一个区块应赋最小值,用于保证按页面出现顺序刷新(详见"刷新与清理"一节)。
独立的 ScrollTrigger(不绑定 tween)
当需要自定义行为而不是驱动某个动画时,可用 ScrollTrigger.create() 直接创建独立触发器,并在回调里基于 self.progress 更新 UI:
ScrollTrigger.create({
trigger: "#id",
start: "top top",
end: "bottom 50%+=100px",
onUpdate: (self) => console.log(self.progress.toFixed(3), self.direction)
});
注意这里的 end: "bottom 50%+=100px" 混用了视口位置 50% 与相对位移 +=100px,ScrollTrigger 支持这种组合表达式。
五、ScrollTrigger.batch():批量触发与回调打包
ScrollTrigger.batch(triggers, vars) 会为每个目标元素创建一个 ScrollTrigger,并把一小段时间间隔内触发的同类型回调(onEnter、onLeave 等)打包成一批统一分发,返回 ScrollTrigger 实例数组。非常适合"每个进入视口的元素都执行一次带 stagger 的入场动画",也是 IntersectionObserver 的轻量替代方案。
- triggers:选择器文本(如
".box")或元素数组; - vars:标准 ScrollTrigger 配置(start、end、once、各回调等)。不要传
trigger(目标本身就是触发器),也不要传与动画绑定相关的选项:animation、invalidateOnRefresh、onSnapComplete、onScrubComplete、scrub、snap、toggleActions。
回调签名差异(关键细节):批量回调接收两个参数,而普通 ScrollTrigger 回调只接收一个实例——
- targets —— 该时间片内触发回调的触发元素数组;
- scrollTriggers —— 对应的 ScrollTrigger 实例数组,可用于读取 progress、direction 或调用
kill()。
vars 中与打包粒度相关的两个选项:
- interval(Number):收集一批的最大时间(秒),默认约为一个 requestAnimationFrame。当某类回调首次触发时计时开始;间隔耗尽或达到 batchMax 即投递本批。
- batchMax(Number | Function):每批最多元素数,满则先触发回调并开启下一批。传入函数可按响应式布局动态返回数值——该函数会在 refresh(resize、标签页重新聚焦等)时重新执行。
基础批量示例(SKILL.md):
ScrollTrigger.batch(".box", {
onEnter: (elements, triggers) => {
gsap.to(elements, { opacity: 1, y: 0, stagger: 0.15 });
},
onLeave: (elements, triggers) => {
gsap.to(elements, { opacity: 0, y: 100 });
},
start: "top 80%",
end: "bottom 20%"
});
用 interval 与 batchMax 做更细粒度控制(SKILL.md):
ScrollTrigger.batch(".card", {
interval: 0.1,
batchMax: 4,
onEnter: (batch) => gsap.to(batch, { opacity: 1, y: 0, stagger: 0.1, overwrite: true }),
onLeaveBack: (batch) => gsap.set(batch, { opacity: 0, y: 50, overwrite: true })
});
第二例把回调参数命名为 batch(即第一参数 targets 的数组),用 overwrite: true 防止同一元素上动画相互覆盖。卡片墙、瀑布流列表等场景可将此模式作为通用范式。
六、ScrollTrigger.scrollerProxy():接入第三方平滑滚动库
当页面使用第三方平滑滚动库(而非 GSAP 自带的 ScrollSmoother)时,ScrollTrigger 默认读取的是元素原生 scrollTop/scrollLeft,无法感知库内部的"假滚动"。ScrollTrigger.scrollerProxy(scroller, vars) 用于覆写 ScrollTrigger 对一个指定滚动容器的读写方式:
- scroller:选择器或元素(如
"body"、".container"); - vars:包含
scrollTop和/或scrollLeft函数。每个函数同时充当 getter 与 setter:被传入参数调用时为 setter;被无参数调用时返回当前值(getter)。scrollTop、scrollLeft至少其一必填。
可选的 vars 项:
- getBoundingClientRect:返回
{ top, left, width, height }的函数(视口通常为{ top: 0, left: 0, width: window.innerWidth, height: window.innerHeight }),当滚动容器真实 rect 非默认值时使用; - scrollWidth / scrollHeight:同样遵循"有参=setter、无参=getter"的读写函数,用于库暴露了不同尺寸口径的情形;
- fixedMarkers(Boolean):为
true时把 markers 视为position: fixed。当滚动容器被 transform(例如平滑滚动库平移了容器)导致 markers 错位时使用; - pinType:
"fixed"或"transform",控制该容器的 pinning 实现方式。主线程滚动场景若出现 pin 抖动用"fixed";若 pin 不跟随则用"transform"。
关键一步:第三方滚动库更新位置时,必须让 ScrollTrigger 收到通知——把 ScrollTrigger.update 注册为监听器(如 smoothScroller.addListener(ScrollTrigger.update)),否则其内部计算全部过期。
技能文档给出的完整代理示例(SKILL.md):
// Example: proxy body scroll to a third-party scroll instance
ScrollTrigger.scrollerProxy(document.body, {
scrollTop(value) {
if (arguments.length) scrollbar.scrollTop = value;
return scrollbar.scrollTop;
},
getBoundingClientRect() {
return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
}
});
scrollbar.addListener(ScrollTrigger.update);
if (arguments.length) 正是用"是否有参数"来区分这次调用是 setter 还是 getter。
七、Scrub:让动画进度跟随滚动
Scrub 把动画进度与滚动位置强关联,制造"滚动驱动"的顺滑观感:
gsap.to(".box", {
x: 500,
scrollTrigger: {
trigger: ".box",
start: "top center",
end: "bottom center",
scrub: true // or number (smoothness delay in seconds), so 0.5 means it'd take 0.5 seconds to "catch up" to the current scroll position.
}
});
scrub: true:进度与滚动 1:1 同步;scrub: 1:以秒为单位的"追赶快慢",滚动停止后播放头用约 1 秒才跟上当前位置,产生惯性缓冲感。
八、Pinning:滚动区间内钉住元素
当滚动处于 start~end 区间时,把触发器元素钉在视口上(默认 position: fixed):
scrollTrigger: {
trigger: ".section",
start: "top top",
end: "+=1000", // pin for 1000px scroll
pin: true,
scrub: 1
}
pin: true即钉住 trigger 自身;也可用字符串/元素指定其它目标;- 不要直接动画被钉住元素本身,应动画其子元素(否则 transform 与 fixed 定位相互干扰);
- pinSpacing 默认
true:ScrollTrigger 会插入 spacer 撑开布局,避免元素变position: fixed后布局塌陷;只有当你自己处理了布局占位时才设置pinSpacing: false(或使用"margin"模式)。
当 pin: true 时,start 的默认值从 "top bottom" 变为 "top top"(见核心配置表),因为钉住的起点通常是元素顶部抵达视口顶部。
九、Markers:开发期可视化触发区间
开发时开启 markers 可直观看到 start/end 线:
scrollTrigger: {
trigger: ".box",
start: "top center",
end: "bottom center",
markers: true
}
进阶形式为对象 { startColor, endColor, fontSize, ... } 以自定义颜色与字号。上线前务必移除或设为 false(见"Do Not"清单)。
十、Timeline + ScrollTrigger:用滚动驱动整条时间线
把 scrollTrigger 直接挂在 timeline 自身上,则该时间线内所有子动画作为一个整体被滚动区间驱动(可选 scrub 平滑):
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".container",
start: "top top",
end: "+=2000",
scrub: 1,
pin: true
}
});
tl.to(".a", { x: 100 }).to(".b", { y: 50 }).to(".c", { opacity: 0 });
时间线的整体进度经由 trigger 的 start/end 区间绑定到滚动。结构红线:ScrollTrigger 只能挂在 timeline 或顶层 tween 上,绝不能挂在 timeline 内部的子 tween 上(gsap.timeline().to(".a", { scrollTrigger: {...} }) 是错误写法),也不要把带 ScrollTrigger 的动画嵌套进父级 timeline。
十一、横向伪滚动(containerAnimation)
经典"讲故事式"长页面模式:垂直滚动驱动一个 section 被 pin 住,section 内部的内容沿水平方向平移(即"假的横向滚动")。实现四步(SKILL.md):
- Pin 这个全屏面板(trigger = 面板);
- 写一个 tween 动画内部容器/包裹层的
x或xPercent(例如x: () => (targets.length - 1) * -window.innerWidth或负xPercent使其左移),该 tween 必须使用ease: "none"; - 给该 tween 挂 ScrollTrigger:
pin: true、scrub: true; - 需要依据横向位移触发其它元素动画时,给那些 ScrollTrigger 设置
containerAnimation指向该 tween。
const scrollingEl = document.querySelector(".horizontal-el");
// Panel = pinned viewport-sized section. .horizontal-wrap = inner content that moves left.
const scrollTween = gsap.to(scrollingEl, {
xPercent: () => Max.max(0, window.innerWidth - scrollingEl.offsetWidth),
ease: "none", // ease: "none" is required
scrollTrigger: {
trigger: scrollingEl,
pin: scrollingEl.parentNode, // wrapper so that we're not animating the pinned element
start: "top top",
end: "+=1000"
}
});
// other tweens that trigger based on horizontal movement should reference the containerAnimation:
gsap.to(".nested-el-1", {
y: 100,
scrollTrigger: {
containerAnimation: scrollTween, // IMPORTANT
trigger: ".nested-wrapper-1",
start: "left center", // based on horizontal movement
toggleActions: "play none none reset"
}
});
Caveats(本模式高危区):
- 使用
containerAnimation的 ScrollTrigger 不支持 pinning 与 snap; - 容器动画必须
ease: "none",否则滚动位置与横向位移无法保持 1:1——这是最常见的错误; - 避免直接横向动画 trigger 元素本身,应动画其子元素;
- trigger 若被移动,需相应补偿其 start/end。
注意:原文示例中用于计算位移的 Max.max 存在笔误,实际应写作 Math.max(0, window.innerWidth - scrollingEl.offsetWidth),写作时请以 Math.max 为准,含义为"横向可移动的最大距离不会小于 0"。
十二、刷新与清理
- ScrollTrigger.refresh():在 DOM/布局变化(新增内容、图片、字体加载、动态数据)后重新计算所有触发器位置。视口 resize 时自动触发 refresh(防抖 200ms),动态内容变化则不会自动刷新,必须手动调用。
- 刷新顺序:refresh 按创建顺序执行(或按
refreshPriority从小到大)。页面中的 ScrollTrigger 应按从上到下(scroll 0 → max)的顺序创建;异步/动态创建无法保证顺序时,必须为每个实例显式设置refreshPriority(页面越靠前数字越小),否则 pin spacer 的插入时机可能破坏布局。 - 销毁清理:SPA 切页或删除元素前要 kill 旧实例,防止它们继续作用于已失效的元素:
ScrollTrigger.getAll().forEach(t => t.kill());
// or kill by the id assigned to the ScrollTrigger in its config object like {id: "my-id", ...}
ScrollTrigger.getById("my-id")?.kill();
- React 场景:优先使用
useGSAP()hook(来自@gsap/reactNPM 包)让清理自动完成;或手动在useEffect返回的 cleanup /gsap.context()中回收全部动画与 ScrollTrigger(详见 gsap-react)。
十三、官方最佳实践清单
技能文档将最佳实践归纳如下(SKILL.md),可作为每次实现前的自检清单:
- ✅ gsap.registerPlugin(ScrollTrigger) 一次,置于任何 ScrollTrigger 使用之前;
- ✅ 影响触发器位置的 DOM/布局变化(新内容、图片、字体)之后调用 ScrollTrigger.refresh();视口 resize 已自动处理(防抖 200ms);
- ✅ React 中使用
useGSAP()hook 确保 ScrollTrigger 与 GSAP 动画随组件卸载被 revert/清理,或在useEffect/useLayoutEffectcleanup 中用手动gsap.context()处理; - ✅ 滚动关联进度用 scrub,离散播放/倒放用 toggleActions,同一触发器不要两者并用(若同时存在,scrub 优先);
- ✅ 基于 containerAnimation 的伪横向滚动,横向 tween/timeline 必须用
ease: "none",保证滚动与横向位置同步; - ✅ 按页面出现顺序(顶部→底部,scroll 0 → max)创建 ScrollTrigger;无法保证顺序时给每个实例设置
refreshPriority(页面首个区块数字最小),使 refresh 按页面顺序执行。
十四、常见误区(Do Not 清单)
- ❌ 把 ScrollTrigger 放在 timeline 的子 tween 上——只能放在 timeline 或顶层 tween 上。错误:
gsap.timeline().to(".a", { scrollTrigger: {...} });正确:gsap.timeline({ scrollTrigger: {...} }).to(".a", { x: 100 }); - ❌ 忘记在 DOM/布局变化(新内容、图片、字体)后调用 ScrollTrigger.refresh();resize 自动处理,动态内容不会;
- ❌ 把带 ScrollTrigger 的动画嵌套进父级 timeline —— ScrollTrigger 只能存在于顶层动画;
- ❌ 使用前忘记 gsap.registerPlugin(ScrollTrigger);
- ❌ 同一 ScrollTrigger 上同时使用 scrub 与 toggleActions;只能二选一,两者共存时 scrub 胜出;
- ❌ 用
containerAnimation伪横向滚动时给横向动画设置除 "none" 以外的 ease,会破坏滚动→位移的 1:1 映射; - ❌ 随机/异步创建 ScrollTrigger 却不设置
refreshPriority;refresh 按创建顺序(或 refreshPriority)执行,错误顺序会影响布局(如 pin spacing)。请自上而下创建或显式分配优先级使其按页面顺序刷新; - ❌ 生产环境遗留 markers: true;
- ❌ 布局变化后忘记 refresh()(影响触发器位置的新内容/图片/字体;视口 resize 自动处理)。
十五、进一步阅读
- 技能本体:.agents/skills/gsap-scrolltrigger/SKILL.md,frontmatter 中的
name/description/license字段即 Agent 检索本技能的结构化入口; - GSAP 技能导航与加载关系:.agents/skills/gsap/README.md(含本技能在 OpenMontage 中的适用边界与 Remotion/HyperFrames 的对比说明);
- 姊妹技能:tween/timeline 基础见 gsap-core 与 gsap-timeline,React 清理见 gsap-react,ScrollSmoother/scroll-to 等滚动周边插件见 gsap-plugins;
- 系统级技能索引与动画运行时路由:skills/INDEX.md、skills/core/hyperframes.md,以及仓库总览 AGENT_GUIDE.md。
ScrollTrigger 的完整上游文档由 GSAP 官方维护(含全部静态方法、示例与版本差异说明),本技能文件基于其稳定 API 提炼了最常用、最易错的部分,实现复杂需求时可结合官方文档与技能清单交叉核对。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00