返回目录

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

第11章 滚动叙事型页面

编排滚动叙事页面,处理固定、横向运动与章节衔接。

本章小节

难度:★★★☆☆ | 用到的GSAP能力:ScrollTrigger.pin、数据驱动动画、多层视差、clipPath reveal | 项目类型:产品展示叙事页面


11.1 需求分析

你有没有看过苹果官网那种页面?往下滚,内容一段一段地"展开"在你眼前——不是简单的"滚到这儿了显示一下",而是你滚多少,动画就走多少。滚动轮变成了录像机遥控器,你是在"播放"一段叙事。

Apple产品页的标志性体验:滚轮不再是"滚动到某个位置",而是"推进一段叙事"。每滚动一屏,用户看到的是一个完整的故事节拍——文字淡入、图片揭示、数据跳动,直到最后抵达CTA。

本章实现一个完整的5段叙事页面:

Hero  →  Feature 1  →  Feature 2  →  Feature 3  →  CTA
封面    核心优势        数据见证        技术揭秘        行动号召

每段内容 pin 在视口,滚动驱动该段内部的动画播放。用户体验:滚动 = 播放进度条。


11.2 页面结构设计

<section class="panel" data-panel="hero">      <!-- Hero封面 -->
<section class="panel" data-panel="feature1">  <!-- 核心优势 -->
<section class="panel" data-panel="feature2">  <!-- 数据见证 -->
<section class="panel" data-panel="feature3">  <!-- 技术揭秘 -->
<section class="panel" data-panel="cta">       <!-- CTA -->

每段 .panel 高度 100vh,内容在内部自由布局。ScrollTrigger 将每个 panel pin 住,滚动"scrub"驱动内部动画。


11.3 ScrollTrigger.pin 的核心机制

pin 工作原理

ScrollTrigger 在创建和 refresh 测量时处理 pin 占位,pinSpacing:true 默认保留固定阶段所需的空间。根滚动器通常使用 fixed,自定义滚动容器通常使用 transform;包含 transform 的祖先等因素也会影响模式。

ScrollTrigger.create({ trigger: panel, start:'top top', end:'+=100%', pin:true });
// +=100% 参考滚动容器的可视高度,不是 panel 自身高度。

关联动画时,滚动进度为 (scroll-start)/(end-start),再限制在 0…1。固定区块、滚动距离和动画秒数是三个独立概念:scrub 将整条时间线映射到这段距离,改变 duration 的相对比例会改变各幕占用的滚动比例。

多段 pin 的 end 值协调

多个 pin 默认会添加占位空间,不需要额外手工补一份相同的总高度。重复添加会造成多余留白。

// 按页面从上到下的顺序创建,使后面的测量包含前面的 pin 占位。
const triggers = panels.map(panel => ScrollTrigger.create({
  trigger: panel, start:'top top', end:'+=100%', pin:true, pinSpacing:true
}));
// 图片、字体或异步内容改变尺寸后:ScrollTrigger.refresh();

只在特定布局方案中使用 pinSpacing:false,此时应自行安排后续内容空间。布局仍异常时检查创建顺序、flex 父容器、已有 sticky/fixed 规则和 refresh 时机。

把这一节跑一遍:滚动叙事与分幕

整条时间线只有一个滚动进度,三幕用透明度交叉过渡。标签是故事节点;各幕时长占比决定它们分配到多少滚动距离。演示将所有 DOM 固定在同一个 pin 容器内。

实验:滚动叙事与分幕

GSAP 3.13 / 实验

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

观察与验证:拖动到 25%、50%、75%,记录哪两幕交叠。画出“滚动距离→时间线时间→本幕属性值”三步映射,再迁移到完整页面。

11.4 数据驱动动画

原理

数据仪表盘里,数字不是"跳"过去的,是从0慢慢涨到目标值的——这就是数据驱动动画。

GSAP 可以直接动画对象的属性,然后用 onUpdate 回调渲染到DOM:

const stats = { value: 0 };

gsap.to(stats, {
  value: 73,              // 目标值:73%
  duration: 2,
  ease: 'power2.out',
  onUpdate: () => {
    document.querySelector('.stat-number').textContent =
      Math.round(stats.value) + '%';
  }
});

GSAP 内部在 0→73 之间插值,onUpdate 每一帧(~16ms)触发一次,数字看起来像在"跳动增长"。GSAP帮你算好了0到73之间的每一个小数,你只要在每一帧把它画到屏幕上就行。

数字格式化

function formatNumber(num) {
  if (num >= 100000000) return (num / 100000000).toFixed(1) + '亿';
  if (num >= 10000) return (num / 10000).toFixed(1) + '万';
  return num.toLocaleString();
}

const stats = { users: 0, marketShare: 0, uptime: 0 };

const dataTL = gsap.timeline({ scrollTrigger: { /* ... */ } });

dataTL.to(stats, {
  users: 200000000,         // 2亿
  duration: 2,
  ease: 'power2.out',
  onUpdate: () => {
    userEl.textContent = formatNumber(Math.floor(stats.users));
  }
}, 0)
.to(stats, {
  marketShare: 73,          // 73%
  duration: 1.5,
  ease: 'power2.out',
  onUpdate: () => {
    shareEl.textContent = Math.round(stats.marketShare) + '%';
  }
}, '-=1')                    // 与上一个动画交叉
.to(stats, {
  uptime: 99.99,
  duration: 1.5,
  ease: 'power2.out',
  onUpdate: () => {
    uptimeEl.textContent = stats.uptime.toFixed(2) + '%';
  }
}, '-=0.8');

数字动画的视觉要点:

  • Math.floor 或 Math.round 都行——floor更"诚实",round更"积极"
  • 数值很大时考虑用缓出曲线 + 长duration,给用户"正在增长"的感觉
  • 超大数值(亿级)用格式化函数处理,避免 200000000 的视觉负担

把这一节跑一遍:普通对象补间

数据值变化、显示格式与滚动触发是三层。此实验隔离数值补间,便于先验证格式化逻辑;滚动层再将同一 Tween 接到 ScrollTrigger。

实验:普通对象补间

GSAP 3.13 / 实验

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

观察与验证:暂停看原始值,再比较整数显示。floor/round 是舍入规则,不代表数据更真实;准确来源与单位需要在业务层明确。

11.5 视差层叠

多层滚动速率

还记得第10章的视差吗?那是鼠标驱动的。这里不一样——滚动驱动的多层视差:你往下滚,不同层的元素速度不一样。

// 三层视差:前景 > 中景 > 背景
// 每层用不同的 scrollTrigger.progress 映射
gsap.to('.parallax-front', {
  y: () => -window.innerHeight * 0.4,    // 前景移动40% 视口高度
  ease: 'none',
  scrollTrigger: {
    trigger: '.panel',
    start: 'top bottom',
    end: 'bottom top',
    scrub: true
  }
});

gsap.to('.parallax-mid', {
  y: () => -window.innerHeight * 0.2,    // 中景移动20%
  ease: 'none',
  scrollTrigger: {
    trigger: '.panel',
    start: 'top bottom',
    end: 'bottom top',
    scrub: true
  }
});

gsap.to('.parallax-back', {
  y: () => -window.innerHeight * 0.1,    // 背景移动10%
  ease: 'none',
  scrollTrigger: {
    trigger: '.panel',
    start: 'top bottom',
    end: 'bottom top',
    scrub: true
  }
});

速率比 4:2:1,前景移动最快,背景最慢——形成深度感。让不同图层以不同速度滚动,大脑就会自动脑补出3D的空间感。


11.6 照片揭示(clipPath reveal)

原理

用 clip-path: inset() 从0%动画到100%:

.reveal-image {
  clip-path: inset(0 100% 0 0);  /* 初始:右侧完全裁切,图片不可见 */
}
gsap.to('.reveal-image', {
  clipPath: 'inset(0 0% 0 0)',   /* 目标:完全不裁切 */
  duration: 1.5,
  ease: 'power3.out',
  scrollTrigger: {
    trigger: '.feature3',
    start: 'top center',
    end: 'center center',
    scrub: 1
  }
});

clipPath 动画的优势:不需要改动图片本身的transform,图片位置保持稳定,只是"显示区域"从左到右展开。想象一下:图片一直在那儿,只是有块布从左到右慢慢揭开——图片没有移动,布在动。


把这一节跑一遍:裁剪揭示

inset 的四个值按上、右、下、左排列。右侧 100% 表示从右边裁剪全部宽度;变成 0% 后全部显示。被裁剪元素的布局宽度仍不变。

实验:裁剪揭示

GSAP 3.13 / 实验

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

观察与验证:拖动到 50% 检查可见宽度,再观察相邻内容是否移动。clipPath 的绘制成本仍应在目标设备测量。

11.7 完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>从补间到叙事:GSAP 动画实战 - 第11章:滚动叙事型页面</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --accent: #FF6B6B;
  --accent2: #4ECDC4;
  --bg: #0A0A0A;
  --text: #FFFFFF;
  --text-dim: rgba(255,255,255,0.6);
  --panel-height: 100vh;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC',
               'Microsoft YaHei', sans-serif;
  background: var(--bg);
  color: var(--text);
  overflow-x: hidden;
}

/* === Panel 通用 === */
.panel {
  position: relative;
  width: 100%;
  height: var(--panel-height);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

.panel-inner {
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 2rem;
  display: flex;
  align-items: center;
  gap: 4rem;
}

/* === Panel 1: Hero === */
.panel-hero {
  flex-direction: column;
  text-align: center;
}

.panel-hero .hero-badge {
  display: inline-block;
  padding: 0.4rem 1.2rem;
  background: rgba(255,107,107,0.15);
  border: 1px solid rgba(255,107,107,0.3);
  border-radius: 100px;
  font-size: 0.85rem;
  color: var(--accent);
  margin-bottom: 2rem;
}

.panel-hero h1 {
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: 800;
  line-height: 1.15;
  max-width: 700px;
}

.panel-hero h1 .highlight {
  background: linear-gradient(135deg, var(--accent), var(--accent2));
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

/* === Panel 2 & 4: 左文右图 === */
.text-col { flex: 1; }
.image-col { flex: 1; display: flex; justify-content: center; }

.text-col h2 {
  font-size: clamp(2rem, 4vw, 3rem);
  font-weight: 700;
  margin-bottom: 1.5rem;
  line-height: 1.2;
}

.text-col p {
  font-size: 1.1rem;
  color: var(--text-dim);
  line-height: 1.8;
}

.image-col .feature-image {
  width: 100%;
  max-width: 480px;
  height: 360px;
  border-radius: 20px;
  background: linear-gradient(135deg, #1a1a2e, #16213e);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 4rem;
  position: relative;
  overflow: hidden;
}

/* === Panel 3: 数据见证 === */
.panel-stats {
  flex-direction: column;
  text-align: center;
  gap: 3rem;
}

.stats-grid {
  display: flex;
  gap: 4rem;
  justify-content: center;
  flex-wrap: wrap;
}

.stat-item {
  text-align: center;
}

.stat-number {
  font-size: clamp(3rem, 6vw, 5rem);
  font-weight: 800;
  background: linear-gradient(135deg, var(--accent), var(--accent2));
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  font-variant-numeric: tabular-nums;  /* 等宽数字,防止跳动时宽度变化 */
}

.stat-label {
  font-size: 0.95rem;
  color: var(--text-dim);
  margin-top: 0.5rem;
}

/* === Panel 4: 照片揭示 === */
.reveal-img {
  width: 100%;
  max-width: 480px;
  height: 360px;
  border-radius: 20px;
  background: linear-gradient(135deg, #1e3a5f, #0f2027);
  clip-path: inset(0 100% 0 0);     /* 初始:完全裁切 */
  display: flex;
  align-items: center;
  justify-content: center;
}

.reveal-img .img-placeholder {
  font-size: 4rem;
  opacity: 0.4;
}

/* === Panel 5: CTA === */
.panel-cta {
  flex-direction: column;
  text-align: center;
}

.panel-cta h2 {
  font-size: clamp(2rem, 5vw, 3.5rem);
  font-weight: 700;
  margin-bottom: 1rem;
}

.panel-cta p {
  color: var(--text-dim);
  font-size: 1.1rem;
  margin-bottom: 2.5rem;
}

.cta-btn {
  display: inline-block;
  padding: 1rem 3rem;
  font-size: 1.15rem;
  font-weight: 600;
  color: #fff;
  background: var(--accent);
  border: none;
  border-radius: 14px;
  cursor: pointer;
  text-decoration: none;
  transition: background 0.3s;
}

.cta-btn:hover {
  background: #e55a5a;
}

/* === 视差元素 === */
.parallax-bg {
  position: absolute;
  inset: -20%;
  z-index: 0;
  pointer-events: none;
  opacity: 0.06;
}

.parallax-bg .circle {
  position: absolute;
  border-radius: 50%;
  border: 2px solid rgba(255,255,255,0.2);
}

.circle-1 { width: 300px; height: 300px; top: 10%; left: 5%; }
.circle-2 { width: 500px; height: 500px; top: 50%; right: -10%; }
.circle-3 { width: 200px; height: 200px; bottom: 20%; left: 30%; }

/* === 滚动进度条 === */
.scroll-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background: linear-gradient(90deg, var(--accent), var(--accent2));
  z-index: 100;
  width: 0%;
}
</style>
</head>
<body>

<!-- 全局滚动进度条 -->
<div class="scroll-progress"></div>

<!-- ===== Panel 1: Hero ===== -->
<section class="panel panel-hero" data-panel="hero">
  <div class="panel-inner">
    <div>
      <div class="hero-badge">2025 全新发布</div>
      <h1>用<span class="highlight">智能协作</span><br>重塑你的工作流</h1>
    </div>
  </div>
  <div class="parallax-bg">
    <div class="circle circle-1"></div>
    <div class="circle circle-2"></div>
    <div class="circle circle-3"></div>
  </div>
</section>

<!-- ===== Panel 2: Feature —— 核心优势 ===== -->
<section class="panel" data-panel="feature1">
  <div class="panel-inner">
    <div class="text-col">
      <h2>实时同步,<br>毫秒级响应</h2>
      <p>基于WebSocket的全双工通信协议,多人协作延迟低于50ms。无论是文档、表格还是白板,每一次修改都能在多端实时呈现。</p>
    </div>
    <div class="image-col">
      <div class="feature-image" style="background: linear-gradient(135deg, #1e3a5f, #16213e);">
        ⚡
      </div>
    </div>
  </div>
  <div class="parallax-bg parallax-bg-mid">
    <div class="circle circle-1"></div>
    <div class="circle circle-2"></div>
  </div>
</section>

<!-- ===== Panel 3: 数据见证 ===== -->
<section class="panel panel-stats" data-panel="feature2">
  <div class="panel-inner" style="flex-direction: column; gap: 3rem;">
    <h2>数据是最好的证明</h2>
    <div class="stats-grid">
      <div class="stat-item">
        <div class="stat-number" id="statShare">0%</div>
        <div class="stat-label">市场份额</div>
      </div>
      <div class="stat-item">
        <div class="stat-number" id="statUsers">0</div>
        <div class="stat-label">全球用户</div>
      </div>
      <div class="stat-item">
        <div class="stat-number" id="statUptime">0%</div>
        <div class="stat-label">服务可用性</div>
      </div>
    </div>
  </div>
</section>

<!-- ===== Panel 4: 技术揭秘 ===== -->
<section class="panel" data-panel="feature3">
  <div class="panel-inner">
    <div class="image-col">
      <div class="reveal-img">
        <div class="img-placeholder">🔒</div>
      </div>
    </div>
    <div class="text-col">
      <h2>端到端加密,<br>安全无忧</h2>
      <p>采用AES-256-GCM加密算法,所有数据在传输和存储过程中全程加密。零知识架构确保即使是平台方也无法访问你的数据。</p>
    </div>
  </div>
</section>

<!-- ===== Panel 5: CTA ===== -->
<section class="panel panel-cta" data-panel="cta">
  <div class="panel-inner" style="flex-direction: column;">
    <h2>准备好了吗?</h2>
    <p>加入 200,000,000 用户,体验下一代协作工具。</p>
    <a href="#" class="cta-btn">免费开始使用</a>
  </div>
</section>

<!-- 空白填充,确保有足够滚动空间 -->
<div style="height: 50vh; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.2);">
  <p>—— 页面结束 ——</p>
</div>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>

<script>
document.addEventListener('DOMContentLoaded', () => {
  gsap.registerPlugin(ScrollTrigger);

  // ============================================
  // 全局滚动进度条
  // ============================================
  const progressBar = document.querySelector('.scroll-progress');
  gsap.to(progressBar, {
    width: '100%',
    ease: 'none',
    scrollTrigger: {
      trigger: 'body',
      start: 'top top',
      end: 'bottom bottom',
      scrub: 0.3
    }
  });

  // ============================================
  // Panel 1: Hero 入场
  // ============================================
  const heroPanel = document.querySelector('[data-panel="hero"]');
  const heroInner = heroPanel.querySelector('.panel-inner');

  ScrollTrigger.create({
    trigger: heroPanel,
    start: 'top top',
    end: 'bottom top',
    pin: true,
    pinSpacing: false,
    anticipatePin: 1,
    animation: gsap.timeline()
      .from(heroInner.querySelector('.hero-badge'), {
        y: 30, opacity: 0, duration: 0.8
      })
      .from(heroInner.querySelector('h1'), {
        y: 60, opacity: 0, duration: 1
      }, '-=0.4')
  });

  // 视差背景(3层不同速率)
  const circles = heroPanel.querySelectorAll('.circle');
  circles.forEach((circle, i) => {
    const speed = [0.3, 0.15, 0.5][i];   // 3个不同速率
    gsap.to(circle, {
      y: window.innerHeight * speed,
      ease: 'none',
      scrollTrigger: {
        trigger: heroPanel,
        start: 'top top',
        end: 'bottom top',
        scrub: true
      }
    });
  });

  // ============================================
  // Panel 2: Feature —— 左文右图
  // ============================================
  const panel2 = document.querySelector('[data-panel="feature1"]');
  const p2Text = panel2.querySelector('.text-col');
  const p2Image = panel2.querySelector('.image-col');

  ScrollTrigger.create({
    trigger: panel2,
    start: 'top top',
    end: 'bottom top',
    pin: true,
    anticipatePin: 1,
    animation: gsap.timeline()
      .from(p2Text, {
        x: -80, opacity: 0, duration: 1, ease: 'power3.out'
      })
      .from(p2Image.querySelector('.feature-image'), {
        x: 80, opacity: 0, scale: 0.8, duration: 1, ease: 'power3.out'
      }, 0)     // 同时开始
  });

  // ============================================
  // Panel 3: 数据见证 —— 数字跳动
  // ============================================
  const panel3 = document.querySelector('[data-panel="feature2"]');
  const statShareEl = document.getElementById('statShare');
  const statUsersEl = document.getElementById('statUsers');
  const statUptimeEl = document.getElementById('statUptime');

  const stats = { share: 0, users: 0, uptime: 0 };

  function formatNumber(num) {
    if (num >= 100000000) return (num / 100000000).toFixed(1) + ' 亿';
    if (num >= 10000) return (num / 10000).toFixed(1) + ' 万';
    return Math.round(num).toLocaleString();
  }

  ScrollTrigger.create({
    trigger: panel3,
    start: 'top top',
    end: 'bottom top',
    pin: true,
    anticipatePin: 1,
    animation: gsap.timeline()
      // 市场份额
      .to(stats, {
        share: 73,
        duration: 1.8,
        ease: 'power2.out',
        onUpdate: () => {
          statShareEl.textContent = Math.round(stats.share) + '%';
        }
      })
      // 全球用户 — 交叉动画
      .to(stats, {
        users: 200000000,
        duration: 1.8,
        ease: 'power2.out',
        onUpdate: () => {
          statUsersEl.textContent = formatNumber(Math.floor(stats.users));
        }
      }, '-=1.2')
      // 服务可用性
      .to(stats, {
        uptime: 99.99,
        duration: 1.5,
        ease: 'power2.out',
        onUpdate: () => {
          statUptimeEl.textContent = stats.uptime.toFixed(2) + '%';
        }
      }, '-=0.8')
  });

  // ============================================
  // Panel 4: 技术揭秘 —— clipPath reveal
  // ============================================
  const panel4 = document.querySelector('[data-panel="feature3"]');
  const revealImg = panel4.querySelector('.reveal-img');
  const p4Text = panel4.querySelector('.text-col');

  ScrollTrigger.create({
    trigger: panel4,
    start: 'top top',
    end: 'bottom top',
    pin: true,
    anticipatePin: 1,
    animation: gsap.timeline()
      .to(revealImg, {
        clipPath: 'inset(0 0% 0 0)',
        duration: 1.2,
        ease: 'power3.out'
      })
      .from(p4Text, {
        x: 60, opacity: 0, duration: 0.8, ease: 'power2.out'
      }, '-=0.6')
  });

  // ============================================
  // Panel 5: CTA —— 行动号召
  // ============================================
  const panel5 = document.querySelector('[data-panel="cta"]');
  const ctaInner = panel5.querySelector('.panel-inner');
  const ctaHeading = ctaInner.querySelector('h2');
  const ctaDesc = ctaInner.querySelector('p');
  const ctaBtn = ctaInner.querySelector('.cta-btn');

  ScrollTrigger.create({
    trigger: panel5,
    start: 'top top',
    end: 'bottom top',
    pin: true,
    anticipatePin: 1,
    animation: gsap.timeline()
      .from(ctaHeading, {
        y: 40, opacity: 0, duration: 0.8
      })
      .from(ctaDesc, {
        y: 20, opacity: 0, duration: 0.6
      }, '-=0.3')
      .from(ctaBtn, {
        scale: 0.8, opacity: 0, duration: 0.7,
        ease: 'back.out(1.7)'
      }, '-=0.2')
  });

  // ============================================
  // 清理函数:刷新所有ScrollTrigger
  // ============================================
  window.addEventListener('resize', () => {
    ScrollTrigger.refresh();
  });
});
</script>
</body>
</html>

11.8 技术解析

anticipatePin 的作用

anticipatePin 根据滚动速度提前应用 pin,减少快速滚动时浏览器先绘制未固定状态的闪动。值不是“提前几秒”,也不是等到触发时才创建 pin-spacer。

ScrollTrigger.create({ trigger:panel, pin:true, start:'top top', end:'+=600', anticipatePin:1 });

动态内容改变尺寸仍需 refresh;anticipatePin 不能代替图片尺寸占位、字体加载处理或布局测量。

数字动画的 tabular-nums

.stat-number {
  font-variant-numeric: tabular-nums;
}

这个CSS属性让数字使用等宽字体变体。当数字从 0 跳动到 9 时宽度不变(数字宽度固定),防止数字变化时的水平抖动。对数字动画至关重要。

视差层的 speed 映射

circles.forEach((circle, i) => {
  const speed = [0.3, 0.15, 0.5][i];
  gsap.to(circle, {
    y: window.innerHeight * speed,
    scrollTrigger: { /* ... */ }
  });
});

不同速率的视差效果本质是:同一滚动距离映射为不同的 y 偏移。这里3个圆分别以0.3、0.15、0.5倍视口高度的速度移动,形成深度层次。


11.9 常见陷阱

陷阱1:多段pin的end值计算

症状:页面滚动到某一段后"卡住",后面的panel无法进入。

原因:多段pin时,前一段的 end 值计算错误,导致滚动距离不够。

排查方法:

ScrollTrigger.create({
  // ...
  markers: true,    // 开发时打开标记,查看start/end线位置
});

修正:

// 根据阅读节奏设置滚动距离;没有“panel高度×动画帧数”的通用公式。
// 如果内部动画总时长对应的"感知距离"需要更长的滚动:
end: '+=200%'   // 2倍滚动容器可视高度

陷阱2:长页面性能优化

滚动触发的 onUpdate 回调在每一帧都会执行。如果每个panel都有复杂的计算(如格式化数字、操作大量DOM),可能导致掉帧。

优化策略:

// 1. 用 requestAnimationFrame 节流
let ticking = false;
onUpdate: () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      // 实际的DOM更新
      ticking = false;
    });
    ticking = true;
  }
}

// 2. 用CSS transform代替top/left(GPU加速)
//    GSAP默认对x/y使用transform,已经是最优解

// 3. 用 will-change 提示浏览器
.panel-inner {
  will-change: transform;
}

实际上GSAP的ScrollTrigger + scrub模式已经在内部做了大量优化。大多数性能问题来自过度的DOM读取(getBoundingClientRect 等触发重排的操作)。

陷阱3:Mobile适配

手机屏幕宽度有限,视差效果和pin在移动端可能产生不预期的行为。

适配策略:

const isMobile = window.innerWidth < 768;

ScrollTrigger.create({
  // ...
  pin: !isMobile,          // 移动端不pin,自然滚动
  scrub: isMobile ? false : 1  // 移动端不scrub,直接播放动画
});

// 或使用 ScrollTrigger.matchMedia
ScrollTrigger.matchMedia({
  '(min-width: 768px)': function() {
    // 桌面端动画逻辑
  },
  '(max-width: 767px)': function() {
    // 移动端:简化或移除动画
  }
});

移动端建议:

  • 减少或取消视差层(只剩2层或1层)
  • pin改为普通scroll(pin: false)
  • 减少stagger元素数量
  • 数字动画保持不变(CPU开销小)