散装触发的问题从哪来
做活动页或者产品落地页的时候,GSAP 加 ScrollTrigger 几乎是标准解法。滚动到一个区域,标题淡入,图片位移,数字滚起来,整个页面就像被一条隐形的导览线牵着走。但动画一多,问题就变了:不是写不出来,而是怎么让这些动画在滚动过程中既不打架、又好调整。这篇文章想聊的就是复杂滚动动画里的时间线编排。

所谓“复杂滚动动画”,我的定义通常是一个页面里同时存在多段呈现顺序、播放时长、滚动联动都不相同的动效。比如一个宣传页,顶部是轮播数据,往下是产品功能列表,再往下变成横向展示的大图卡片。这些模块彼此独立,却又要让用户感觉是在同一套节奏里。
很多早期滚动页面,会习惯给每个元素单独写一个 ScrollTrigger。动画少的时候这样很直白,可页面里一旦出现五六个区块,每个区块又有标题、描述、图片、背景装饰,你要同时维护十几个触发点。改一个元素的入场顺序,就得去翻不同 trigger 的 start、end,可能还要牵涉 toggleActions,往往越改越乱。
另一个典型问题隐藏在快速滚动里。用户滚得快的时候,各触发点的命中时间不一样,动画时长也不一样,视觉上就会出现一种非常散的感觉。单独看每个模块问题不大,但整页节奏明显是散的。为什么会这样?因为每个动画都只拿到了滚动的一部分进度,没有一条统一时间线串联它们。这就是复杂页面里值得用 timeline 的原因。
把动画交给一条时间线
ScrollTrigger 驱动 timeline 的原理其实很简单。你把整段动画写进一条 timeline,然后只给这条 timeline 挂上 ScrollTrigger。滚动进度和整段动画之间的映射关系就是一对一的,不用再关心每个动画是在哪个滚动百分比开始的。
const sceneTl = gsap.timeline({
scrollTrigger: {
trigger: "#feature-section",
start: "top 70%",
end: "bottom 10%",
scrub: 1
}
});
sceneTl
.fromTo(".feature__title", { y: 80, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.8 })
.fromTo(".feature__desc", { y: 40, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.6 }, "-=0.4")
.fromTo(".feature__media", { scale: 1.1 }, { scale: 1, duration: 1.2 }, "<");
这段代码里,标题和描述不是严格按照 duration 数值相加去对齐,而是通过 -=0.4 和 < 建立相对位置。< 表示“和前一个动画同时开始”,-=0.4 表示“比前一个动画结束时间提前 0.4 秒开始”。熟悉 timeline 位置参数的人应该清楚,这种碎片化编排才能真正把动画组织成有逻辑的序列。
scrub: 1 意味着滚动停止后,时间线还会用 1 秒去追赶目标状态,视觉上有种“粘着感”。对总时长比较长的复杂场景,一般设置在 0.8 到 2 之间比较自然。但要注意,太大会让动画明显跟不上手,并不是越大越跟手。
这样做的另一个好处是,以后想调整动画顺序,不需要去改每个 tween 触发区域,只需要调整 timeline 里的位置参数和 duration。ScrollTrigger 关心的只是整条时间线什么时候开始、什么时候结束。
进阶:横向滚动用 containerAnimation
横向滚动是时间线编排里最有挑战的一类。一个横向轨道本身用 ScrollTrigger 做 pin,然后 timeline 驱动轨道横向位移。此时轨道内部的卡片还要根据进入视口播放进入动画。很多人第一反应是给卡片也单独挂 ScrollTrigger,但卡片实际只在横向坐标上移动,主滚动条的坐标没有变化,两个触发点很容易互相打架。
正确做法是分两层:外层轨道用 timeline 驱动,内层动画的 ScrollTrigger 通过 containerAnimation 参数挂到外层 timeline 上。
const horizontalTl = gsap.timeline({
scrollTrigger: {
trigger: "#gallery",
pin: true,
scrub: 1,
end: () => "+=" + (track.scrollWidth - window.innerWidth)
}
});
gsap.utils.toArray(".gallery__card").forEach((card) => {
gsap.fromTo(card, { x: 120, autoAlpha: 0 }, {
x: 0,
autoAlpha: 1,
duration: 0.6,
scrollTrigger: {
trigger: card,
containerAnimation: horizontalTl,
start: "left 85%",
toggleActions: "play none none reverse"
}
});
});
containerAnimation 会把内层卡片的触发时机映射到外层水平轨道的坐标系里。注意这里的 start、end 不再是浏览器可视区,而是横向容器的滚动位置。调试时别用普通 ScrollTrigger 的经验卡像素,用元素与容器的相对位置判断会稳得多。
这个场景不应该直接把所有卡片动画放进 horizontalTl 里面堆在一起。外层 timeline 负责轨道位移,内层卡片通过 containerAnimation 各自管理触发,内外两条线各自的职责非常清楚,查找问题也方便。
常见误区:时间线不能解决所有编排问题
时间线不是万能胶,以下三种情况很容易被带偏。
- 把所有交互都塞进 timeline。hover 动画、鼠标跟随、数据更新导致的红点闪烁,这些和滚动没有直接关系。混进 timeline 后,一旦你要单独做跳过逻辑,会牵连其他无关动画。
- 不理解 timeline 总时长和滚动范围的换算。ScrollTrigger 会把 start 和 end 的像素差平均分配给 timeline 的总时长。如果总时长 10 秒,某个动画只有 0.5 秒,它在滚动区间里就只占很小一段。想控制比例,要么调 duration,要么用 label 明确节点,而不是把所有动画都堆在一条线里靠运气。
- 过早引入嵌套 timeline。嵌套 timeline 适合横滚区域这样有明显隔离的场景。如果只是顺序、交叠关系,一条扁平 timeline 加上 addLabel 完全够用。嵌套层级越多,越难判断当前正在播放的是哪一段。
三种编排方式怎么选
结合我自己的使用经验,三种方式之间并不是越高级越好,更多看页面复杂度。
| 组织方式 | 同步能力 | 可维护性 | 适合的场景 |
|---|---|---|---|
| 每个动画单独挂 ScrollTrigger | 弱,各动画并行 | 差,触发点分散 | 页面只有一两处独立动效 |
| 单条 timeline 绑定一个 ScrollTrigger | 强,节点可控 | 中,适合单个区块 | 功能模块内的连续滚动动画 |
| 多条 timeline 嵌套或组合 | 强,可分层 | 依赖设计,需先分层 | 横向滚动、多场景复杂编排 |
表格里的“单独挂 ScrollTrigger”并不代表不能用,而是它更适合动效数量少、彼此没有强依赖的场景。一旦需要多个动画同时推进或者顺序错位配合,就值得用时间线把这个协调工作交给代码结构。
落地清单
- 先在页面里画滚动动效草稿,标出每个动画进入点和退出点,给每个节奏块起名字,不要直接开工。
- 每个区块内部用一条时间线,区块之间不强行合并。合并得太狠,改一个区块会波及另一块。
- 时间线里的关键节点用 addLabel 标记,后续用 label 定位,避免魔法数字。
- 在 defaults 里设置统一的 ease,比如
ease: "power2.out",减少各 tween 各自为政。 - 异步内容加载完成或图片插入后,记得调用
ScrollTrigger.refresh(),否则基于旧布局算出的 start 和 end 会脱节。 - 属性尽量用 transform 和 opacity,减少 top、left 这类触发重排的属性,滚动动画密集时对帧率影响明显。
最后
时间线编排的真正作用,是把散落的动效收敛成可描述、可推理的播放顺序。滚动是用户输入,时间线负责把这个输入翻译成页面的视觉叙事。对于超过两三处联动的动效,我都建议先画一条时间线,不是迷信它,而是它逼你把动画之间的时间关系讲清楚。
GSAP 和 ScrollTrigger 的参数还有很多可玩的空间,但撑起整个体验的核心,始终是你能不能把时间关系想明白。写代码之前花十几分钟把 timeline 草图理顺,比之后反复调试十几个触发点划算得多。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/918/