返回目录

从补间到叙事:GSAP 动画实战 · 公开阅读

第6章:Stagger 交错动画

调整间隔与起始位置,观察网格元素的交错动画。

本章小节

6.1 stagger 是什么

想象一个合唱团——指挥一挥手,所有成员不是同时站起来,而是从第一排到最后一排依次起立。这种"有先有后"的节奏感,就是 stagger 做出来的效果。

没有 stagger 时,10 个元素同时动画——齐刷刷,像机器人列队:

gsap.to(".item", { y: -20, opacity: 1, duration: 0.5 });
// 10 个 .item 齐刷刷一起动

加上 stagger,10 个元素就有了"呼吸感":

gsap.to(".item", { y: -20, opacity: 1, duration: 0.5, stagger: 0.1 });
// 10 个 .item 依次动,每个间隔 0.1 秒

stagger 让一组元素按固定间隔依次执行动画,产生有节奏的视觉序列效果。

6.2 数值 stagger——最简单的起步

一个数字就能搞定,最容易上手:

gsap.to(".card", {
  y: 0,
  opacity: 1,
  duration: 0.6,
  stagger: 0.15,     // 每个元素间隔 0.15 秒
});
stagger 值行为
0.1元素 1 开始 → 0.1s 后元素 2 开始 → 0.1s 后元素 3 开始
0所有元素同时开始(等于没加 stagger)
-0.1负值等于反向顺序,但绝对值仍是间隔

6.3 对象 stagger——全参数详解

只有一个数字的话,控制粒度太粗了。对象形式的 stagger 有 4 个参数联合作战:

gsap.to(".card", {
  y: 0,
  opacity: 1,
  duration: 0.6,
  stagger: {
    each: 0.15,      // 每个元素的间隔时间
    from: "center",  // 从哪个元素开始
    grid: "auto",    // 网格模式
    amount: 1.2,     // 总时间约束
    ease: "power2.inOut", // stagger 自身的缓动
  }
});

6.3.1 each——间隔时间

stagger: { each: 0.15 }
// 等价于 stagger: 0.15

6.3.2 amount——总时间约束

当不确定间隔应该设多少,但需要所有元素在固定时间内全部启动时,用 amount:

stagger: { amount: 1.5 }
// 不管有多少个元素,1.5 秒内全部开始
// GSAP 自动计算 each = 1.5 / (元素数量 - 1)

each 和 amount 互斥——你只需指定其中一个:

// 方式一:精确控制每个间隔
stagger: { each: 0.1 }

// 方式二:精确控制总时间
stagger: { amount: 2 }
参数适用场景
each元素数量不固定,希望每个元素有固定的间隔
amount希望所有元素在规定时间内全部启动

把这一节跑一遍:each 与 amount

8 个元素从索引 0 开始。each=0.15 时启动跨度为 7×0.15=1.05 秒;amount=0.15 时整个启动跨度仅 0.15 秒。amount 不包含每个元素自己的 duration。

实验:each 与 amount

GSAP 3.13 / 实验

亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。

观察与验证:把同一个 0.15 分别当 each/amount,读取启动时刻列表。默认单个 duration=2,整组结束分别为 3.05 与 2.15 秒。

6.3.3 from——6 种起始位置

from 参数决定交错动画从哪个元素开始:

stagger: { from: "center" }  // 从中间往两边扩散
stagger: { from: "edges" }   // 从两端往中间收拢
stagger: { from: "end" }     // 从最后一个开始
stagger: { from: "start" }   // 从第一个开始(默认)
stagger: { from: "random"}    // 随机顺序
stagger: { from: 3 }         // 从索引 3 的元素开始

视觉效果对比

// 不同 from 值的效果对比

// 从左到右依次出现(默认)
gsap.to(".btn", { y: 0, opacity: 1, duration: 0.4, stagger: { each: 0.1, from: "start" } });

// 从右到左依次出现
gsap.to(".btn", { y: 0, opacity: 1, duration: 0.4, stagger: { each: 0.1, from: "end" } });

// 从中间向两边扩散
gsap.to(".btn", { y: 0, opacity: 1, duration: 0.4, stagger: { each: 0.1, from: "center" } });

// 从两边向中间收拢
gsap.to(".btn", { y: 0, opacity: 1, duration: 0.4, stagger: { each: 0.1, from: "edges" } });

from: index 用法

当你需要从特定元素开始时:

// 从第 2 个元素(索引为 1)开始
gsap.to(".item", { scale: 1, stagger: { each: 0.1, from: 1 } });

6.4 grid 模式——按行列展开

处理 2D 网格布局(照片墙、卡片矩阵)时,grid 参数让交错按行或列展开:

gsap.to(".grid-item", {
  scale: 1,
  opacity: 1,
  duration: 0.5,
  stagger: {
    each: 0.05,
    grid: [4, 5],   // [行数, 列数] → 4 行 5 列的网格
    from: "center",
  }
});

grid 详解

grid: [3, 4]      // 3 行,每行 4 个元素,共 12 个
grid: "auto"      // GSAP 自动感应网格
grid: [4, 5]

当使用 grid: "auto" 时,GSAP 会根据元素的位置(getBoundingClientRect)自动判断行列结构。但如果你知道确切的行列数,手动指定 [row, col] 更可靠——不会有自动检测不准的意外。

grid + from 组合效果

// 从左上角开始,逐行展开
stagger: { grid: [3, 4], from: "start" }

// 从中间行向外展开
stagger: { grid: [3, 4], from: "center" }

// 从右下角开始(反向)
stagger: { grid: [3, 4], from: "end" }

// 从网格中心点辐射
stagger: { grid: [3, 4], from: [1, 1] }  // 从第 1 行第 1 列开始

把这一节跑一遍:网格交错与起点

grid 定义距离计算的行列结构,from 决定距离原点。DOM 排序、显示布局与 grid 不一致时,视觉顺序会让人误以为延迟算错。stagger.ease 影响启动分布,Tween ease 影响每个目标的运动。

实验:网格交错与起点

GSAP 3.13 / 实验

亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。

观察与验证:选择 center、edges、end,看最先启动的单元。将每个元素运动变慢,更容易分清“谁先开始”和“开始后怎么动”。

6.5 stagger 与 Timeline 结合

在 Timeline 中,stagger 让交错序列作为时间线的一个"片段"——导航先依次落下,然后标题滑入,最后卡片展开:

const tl = gsap.timeline();

tl.from(".nav-item", {
  y: -20,
  opacity: 0,
  duration: 0.4,
  stagger: 0.1,     // 导航项依次下落
})
.from(".hero-title", {
  y: 50,
  opacity: 0,
  duration: 0.8,
}, "-=0.2")       // 导航还没播完就提前开始标题
.from(".card", {
  y: 30,
  opacity: 0,
  duration: 0.5,
  stagger: { each: 0.08, from: "center" },  // 卡片从中间向两边展开
}, "-=0.4");

交错在整个 Timeline 中的位置

把时间轴拉直了看:

时间线: |---导航交错(0.4+9*0.1=1.3s)---|---标题(0.8s)---|---卡片交错...---|
       ↑ -=0.2 让标题提前                ↑ -=0.4 让卡片提前

stagger 会为每个目标元素创建一个独立 tween,并按 stagger 值偏移各自的开始时间。

6.6 实战示例

示例一:菜单项依次入场

gsap.from(".menu-item", {
  x: -40,
  opacity: 0,
  duration: 0.5,
  stagger: {
    each: 0.08,
    from: "start",
  },
  ease: "power2.out",
});

示例二:卡片瀑布式出现

gsap.fromTo(".card",
  { y: 100, opacity: 0, rotation: 5 },
  {
    y: 0,
    opacity: 1,
    rotation: 0,
    duration: 0.7,
    stagger: {
      each: 0.12,
      from: "start",
      ease: "power3.out",  // stagger 自身的缓动
    },
    ease: "back.out(1.4)", // 每个卡片的缓动
  }
);

注意:这里有两个 ease——外层的 ease: "back.out(1.4)" 控制每个卡片动画的缓动;内层 stagger.ease 控制 stagger 分布密度(前面的元素间隔短,后面的间隔长)。

stagger.ease 的效果:

stagger: {
  each: 0.1,
  ease: "power2.in",
  // 前面的元素间隔小(紧密),越往后间隔越大(稀疏)
}

示例三:文字逐字动画

// HTML: <h1 class="split-text">Hello World</h1>
// JS 中将每个字拆成 span
const text = document.querySelector(".split-text");
const chars = text.textContent.split("");

text.innerHTML = chars.map(c => `<span>${c}</span>`).join("");

gsap.from(".split-text span", {
  y: 80,
  opacity: 0,
  rotationX: -90,
  duration: 0.6,
  stagger: 0.05,
  ease: "back.out(1.7)",
});

示例四:网格卡片——从中心辐射

gsap.from(".gallery-item", {
  scale: 0,
  opacity: 0,
  duration: 0.5,
  stagger: {
    amount: 1.5,
    grid: [3, 4],
    from: "center",
  },
  ease: "back.out(2)",
});

示例五:交替方向——左右错落入场

gsap.from(".feature-item", {
  x: (i) => i % 2 === 0 ? -100 : 100,
  opacity: 0,
  duration: 0.8,
  stagger: 0.15,
  ease: "power3.out",
});
// 通过回调函数,偶数项从左边来,奇数项从右边来

6.7 常见陷阱

陷阱一:stagger 与 repeat 不是"各走各的"

现象:设了 stagger 和 repeat: -1,期望每个元素独立循环——但行为完全不对。

原因:外层 repeat 重复整组动画。需要每个目标独立往返时,可把 repeat、yoyo 写进 stagger 对象,例如 stagger: { each:0.1, repeat:1, yoyo:true };也可为各目标创建独立 Tween。

// 错误期望:每个 .item 独立重复
gsap.to(".item", {
  y: -10,
  repeat: -1,
  yoyo: true,
  stagger: 0.1,
  duration: 0.3,
});
// 实际行为:整个 tween 完成后再重复,不是每个 item 独立循环

解决:如果每个元素都需要独立循环,必须给每个元素单独创建 tween:

document.querySelectorAll(".item").forEach((el, i) => {
  gsap.to(el, {
    y: -10,
    repeat: -1,
    yoyo: true,
    duration: 0.3,
    delay: i * 0.1,
  });
});

陷阱二:grid 参数与实际布局对不上

现象:指定了 grid: [3, 4] 但动画顺序不按预想的走。

原因:grid 参数假设元素按文档流顺序排列。如果 DOM 顺序和视觉顺序不一致,或者用 CSS Grid/Flexbox 重排了视觉位置,顺序就会乱。

解决:

  • 确保 DOM 顺序和视觉顺序一致
  • 在 gsap.utils.toArray 时手动排序:
const items = gsap.utils.toArray(".grid-item").sort((a, b) => {
  // 按自定义逻辑排序
  return a.dataset.order - b.dataset.order;
});
gsap.from(items, { opacity: 0, stagger: 0.1 });

陷阱三:大量元素时的性能问题

现象:500 个以上元素做 staggered 动画,页面明显卡成幻灯片。

原因:每个 staggered 元素都创建一个独立的 tween 实例,500+ 个 tween 同时运行。

解决:

// 策略一:限制 amount,减少同时运行的 tween
stagger: { amount: 2 }
// 而非 stagger: { each: 0.01 }(后者导致大量 tween 同时运行)

// 策略二:降低复杂度——只对 opacity 做动画
gsap.from(".item", { opacity: 0, stagger: { amount: 1.5 } });
// 避免 transform 叠加:scale + rotation + x/y 同时用

// 策略三:will-change 但谨慎使用
.item { will-change: opacity, transform; }
// 只在动画期间使用,动画结束后移除

陷阱四:stagger.ease 和 tween.ease 别搞混

现象:设了 stagger.ease 但感觉效果不明显,或者搞不清两个 ease 的区别。

原因:stagger.ease 影响的是 stagger 的时间分布密度,不是每个元素动画的缓动。两种 ease 各管各的:

// 两个 ease 作用完全不同
gsap.to(".item", {
  y: 0,
  ease: "back.out",           // 每个元素的动画缓动——它怎么"弹"
  stagger: {
    each: 0.1,
    ease: "power2.in",        // stagger 的时间分布缓动——它们"排队"的节奏
  }
});

stagger.ease: "power2.in" 意味着:前面元素间隔小(密集出场),越往后间隔越大(稀疏出场)。

陷阱五:stagger 与 fromTo 的初始状态

fromTo 配合 stagger 不存在“初始状态保持时间耗尽”的机制。每个目标有自己的启动延迟,默认立即渲染起始值。后面的元素异常时,检查是否有另一个 tween、CSS transition 或框架更新写入同一属性。

需要统一初始状态时,可以 gsap.set 后再 to;需要延迟到各自开始时才写起始值时,使用 immediateRender:false 并确认不会出现可见跳变。

gsap.set(items, { opacity: 0, y: 50 });
gsap.to(items, { opacity: 1, y: 0, duration: 0.6, stagger: 0.15 });

时长算式:N 个目标、每个时长 d、间隔 e,从第一个到最后一个的启动跨度为 (N-1)*e,整组完成时间为 d+(N-1)*e。