返回目录

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

第10章 炫酷首页动画

组合 Hero 视差、文字入场与按钮反馈,构建首页动画。

本章小节

难度:★★☆☆☆ | 用到的GSAP能力:Timeline、SplitText、ScrollTrigger、基础动画属性 | 项目类型:产品首页Hero动画


10.1 需求分析

产品首页动画的需求往往很模糊——"这个首页要有冲击力"。Dribbble上的参考动不动三秒转场、文字逐个飞出。拆开来看,所谓的"冲击力"其实是三层:

  1. 视觉冲击——用户打开页面的前3秒,决定"要不要继续看"。这不是玄学,是实实在在的眼球争夺战。
  2. 品牌传达——动画的节奏快慢、弹性大小,传递的是产品气质。快而利落是科技感,柔而舒展是温暖感,重而有力是力量感。
  3. 行动引导——所有花哨的动画最终目的只有一个:把用户的注意力领到按钮上,让他"点下去"。

先用动画吸引用户,再用节奏传达气质,最后把用户推给CTA按钮。

本章项目要实现以下动画效果:

  • Hero区域背景视差:鼠标移动时,背景图片跟随产生微妙的偏移
  • 文字逐字登场:标题文字按字逐个入场,副标题跟随淡入,CTA按钮弹性弹出
  • CTA按钮微交互:hover时放大、变色、发光,点击时有按压反馈

所有这些由一个主Timeline编排,确保动画序列严谨、可暂停、可回放。


10.2 需求拆解

将整体需求拆解为3个子动画序列:

主Timeline (入场序列)
├── 子序列1:Hero背景视差(由mousemove事件驱动,独立于Timeline)
├── 子序列2:文字逐字入场
│   ├── 标题 SplitText → stagger 逐字滑入
│   ├── 副标题 跟随淡入 ↑
│   └── CTA按钮 弹性弹出 scale:0→1
└── 子序列3:CTA按钮hover微交互
    ├── scale: 1→1.05
    ├── backgroundColor 渐变
    └── boxShadow 发光

10.3 Hero区域背景视差

原理

视差的原理很简单:不同距离的东西,在你移动时看起来运动速度不一样。坐火车时,近处的树跑得飞快,远处的山几乎不动。

在网页里,我们用鼠标位置代替"真实距离"。鼠标在屏幕中央时背景不偏,鼠标往右上方移时背景也往右上方偏移——不同图层以不同速率响应同一输入。这里用鼠标位置作为输入:

// mousemove 事件 → 映射为背景偏移
// 偏移量 = 鼠标相对视口中心的距离 × 灵敏度系数
const sensitivity = 0.02;
const moveX = (e.clientX - window.innerWidth / 2) * sensitivity;
const moveY = (e.clientY - window.innerHeight / 2) * sensitivity;

gsap.to('.hero-bg', {
  x: moveX,
  y: moveY,
  duration: 1,      // 平滑跟随,不是即时跳变
  ease: 'power2.out'
});

关键点:duration设为1秒,不是0。如果设为0,背景会瞬间跳变,产生"抽帧"感。GSAP的duration在这里充当了"平滑滤波器"。鼠标动了,背景要"慢半拍"跟上,才有那种丝滑的跟随感。

对比:原生CSS vs GSAP视差

/* CSS方式:每次mousemove都触发transition,性能差且抖动 */
.hero-bg {
  transition: transform 0.3s ease-out;
}
// GSAP方式:利用覆盖式动画,自动合并连续调用
// 必须显式指定 overwrite:"auto",或使用 quickTo 复用 Tween
gsap.to('.hero-bg', {
  x: moveX, y: moveY,
  duration: 1, ease: 'power2.out'
});

GSAP 默认 overwrite:false,连续调用不会自动终止旧动画。可显式使用 overwrite:"auto" 处理冲突属性;高频指针输入更适合 quickTo,它复用已有 Tween。


把这一节跑一遍:指针视差与 quickTo

两层使用相同比例的输入、不同位移系数,产生深度差。输入坐标相对本区中心,不能直接拿整个窗口的 clientX 当本区偏移。quickTo 复用 Tween,不会每次输入都新建四个动画。

实验:指针视差与 quickTo

GSAP 3.13 / 实验

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

观察与验证:指针慢慢移向右上角,再移出。键盘用户可点击方位按钮验证相同逻辑;这也是触屏没有 hover 时需要替代交互的原因。

10.4 文字逐字入场

SplitText 拆分

逐字入场的效果,让每个字都有分量,而不是整段话一口气吐出来。

// 先加载 GSAP 核心,再加载同版本的免费 SplitText 插件
// CDN: https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js
gsap.registerPlugin(SplitText);
const titleSplit = new SplitText('.hero-title', { type: 'chars' });

type: 'chars' 将字符包裹成可独立变换的元素。默认仍可在同一行内排版;inline-block 不等于每个字符换行。

拆分前:

<h1 class="hero-title">重新定义生产力</h1>

拆分后(DOM结构):

<h1 class="hero-title">
  <div class="char" style="display:inline-block">重</div>
  <div class="char" style="display:inline-block">新</div>
  <div class="char" style="display:inline-block">定</div>
  <div class="char" style="display:inline-block">义</div>
  <div class="char" style="display:inline-block">生</div>
  <div class="char" style="display:inline-block">产</div>
  <div class="char" style="display:inline-block">力</div>
</h1>

注意:SplitText 为每个字符设置了 display:inline-block,这是为了让它支持 transform(inline元素无法transform)。中文字符天然是方块字,这个处理很自然。

stagger 动画

gsap.from(titleSplit.chars, {
  y: 60,               // 从下方60px滑入
  opacity: 0,
  rotationX: -90,      // 3D翻转效果
  stagger: 0.05,       // 每个字间隔0.05秒
  duration: 0.6,
  ease: 'back.out(1.7)' // 弹性缓出,末尾带微小回弹
});

stagger: 0.05 是逐字动画的灵魂。7 个字的启动跨度为 (7-1) × 0.05 = 0.30 秒,加上单字 duration 0.6 秒,总时长约 0.90 秒——节奏明快,不拖沓。stagger就是"排队入场"的间隔时间,间隔太小字挤在一起,太大等得人着急,0.05秒刚刚好。

副标题跟随 + 按钮弹出

// 副标题在标题动画开始后0.4秒启动
gsap.from('.hero-subtitle', {
  y: 30,
  opacity: 0,
  duration: 0.6,
  delay: 0.4
});

// 按钮从0缩放到1,弹性曲线制造"弹出感"
gsap.from('.cta-button', {
  scale: 0,
  duration: 0.8,
  delay: 0.6,
  ease: 'elastic.out(1, 0.5)'
});

elastic.out 的弹性回弹让按钮看起来是"可点击"的——物理感增强了交互暗示。


把这一节跑一遍:SplitText 字符入场

此处运行真实 SplitText,而不是手工 split("");组合 emoji 不能简单按 UTF-16 码元拆分。逐字 transform 需要可独立变换的包装元素,但这些元素仍可正常同行排版。

实验:SplitText 字符入场

GSAP 3.13 / 实验

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

观察与验证:播放并拖动进度,观察中文字与 👩‍💻。重置应还原原文再创建一次;拆行场景还要等待字体和处理宽度变化。

10.5 用 Timeline 编排完整序列

将以上所有步骤纳入一个主Timeline:

const masterTL = gsap.timeline({ defaults: { ease: 'power3.out' } });

// === 阶段1:标题逐字入场 ===
masterTL.from(titleSplit.chars, {
  y: 60,
  opacity: 0,
  rotationX: -90,
  stagger: 0.05,
  duration: 0.6
});

// === 阶段2:副标题 + 按钮 ===
// 用 "<" 表示与上一个动画同时开始
// 用 ">-0.2" 表示在上一个动画结束前0.2秒开始(交叉淡入)
masterTL.from('.hero-subtitle', {
  y: 30,
  opacity: 0,
  duration: 0.6
}, '-=0.3');

masterTL.from('.cta-button', {
  scale: 0,
  duration: 0.8,
  ease: 'elastic.out(1, 0.5)'
}, '-=0.2');

Timeline的位置参数说明:

参数含义说明
不写(默认)追加到当前时间线末尾无重叠的串行序列中,就是上一个动画结束后
'-=0.3'在上一个动画结束前0.3秒开始抢跑0.3秒,制造交叉感
'<'与上一个动画同时开始一起出场
'>+0.5'在上一个动画结束后0.5秒开始空半秒再出来
'+=0.2'在当前时间线末尾之后0.2秒开始需要相对前一个动画的开始位置时,使用 '<0.2'

Timeline就像一个导演,你只要告诉它"谁、什么时候、怎么上台",它帮你搞定全场的节奏。

使用Timeline的好处:调用 masterTL.pause() / masterTL.resume() / masterTL.reverse() 可以整体控制整个入场序列。


把这一节跑一遍:首页入场编排

三个阶段都加入同一时间线,重叠相对插入时的末端计算。autoAlpha 同时影响 opacity/visibility,适合避免隐藏入场元素仍然接受指针事件;可用性还需考虑焦点和实际事件绑定。

实验:首页入场编排

GSAP 3.13 / 实验

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

观察与验证:定位到 20%、50%、90%,逐段说明哪个元素进入、哪个仍隐藏。改变单段时长后重新计算总时长与重叠。

10.6 完整代码

以下是可直接在浏览器中打开的完整Demo。

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

:root {
  --accent: #6C63FF;
  --accent-hover: #5A52E0;
  --bg-dark: #0F0F1A;
  --text-primary: #FFFFFF;
  --text-secondary: rgba(255,255,255,0.7);
}

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

/* === Hero Section === */
.hero {
  position: relative;
  width: 100%;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

/* 背景层 —— 视差元素 */
.hero-bg {
  position: absolute;
  inset: -10%;          /* 略大于视口,避免移动时露白边 */
  background:
    radial-gradient(ellipse at 30% 20%, rgba(108,99,255,0.3) 0%, transparent 60%),
    radial-gradient(ellipse at 70% 80%, rgba(0,200,255,0.2) 0%, transparent 60%),
    radial-gradient(ellipse at 40% 60%, rgba(255,99,132,0.15) 0%, transparent 50%);
  pointer-events: none;
  z-index: 0;
}

/* 网格纹理装饰 */
.hero-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
  background-size: 60px 60px;
}

/* 内容层 */
.hero-content {
  position: relative;
  z-index: 1;
  text-align: center;
  max-width: 800px;
  padding: 0 2rem;
}

.hero-title {
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: 800;
  line-height: 1.2;
  letter-spacing: 0.02em;
  margin-bottom: 1.5rem;
  /* SplitText会拆分chars,每个char需要perspective才能看到3D效果 */
  perspective: 400px;
}

.hero-title .char {
  /* SplitText生成的char元素默认就是inline-block */
}

.hero-subtitle {
  font-size: clamp(1rem, 2vw, 1.25rem);
  color: var(--text-secondary);
  line-height: 1.8;
  margin-bottom: 3rem;
  max-width: 520px;
  margin-left: auto;
  margin-right: auto;
}

/* === CTA Button === */
.cta-button {
  display: inline-block;
  padding: 1rem 2.5rem;
  font-size: 1.1rem;
  font-weight: 600;
  color: #fff;
  background: var(--accent);
  border: none;
  border-radius: 12px;
  cursor: pointer;
  transition: background 0.3s;  /* 仅颜色用CSS transition,其余用GSAP */
  user-select: none;
  position: relative;
}

/* Hover —— 由GSAP接管 */
.cta-button:hover {
  background: var(--accent-hover);
}

/* 滚动提示箭头 */
.scroll-hint {
  position: absolute;
  bottom: 2rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  opacity: 0;
}

.scroll-hint span {
  font-size: 0.8rem;
  color: var(--text-secondary);
  letter-spacing: 0.1em;
}

.scroll-hint .arrow {
  width: 24px;
  height: 24px;
  border-right: 2px solid rgba(255,255,255,0.4);
  border-bottom: 2px solid rgba(255,255,255,0.4);
  transform: rotate(45deg);
  animation: bounceDown 2s infinite;
}

@keyframes bounceDown {
  0%, 100% { transform: rotate(45deg) translate(0, 0); }
  50% { transform: rotate(45deg) translate(6px, 6px); }
}

/* === Demo内容区 === */
.demo-section {
  min-height: 60vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4rem 2rem;
  background: #1A1A2E;
}

.demo-section p {
  color: var(--text-secondary);
  font-size: 1.2rem;
  text-align: center;
}
</style>
</head>
<body>

<!-- ===== Hero ===== -->
<section class="hero">
  <div class="hero-bg"></div>

  <div class="hero-content">
    <h1 class="hero-title">重新定义生产力</h1>
    <p class="hero-subtitle">
      融合人工智能与极致设计,打造下一代工作方式。
      让每一个操作都精准、流畅、优雅。
    </p>
    <button class="cta-button" id="ctaBtn">立即体验</button>
  </div>

  <div class="scroll-hint">
    <span>向下滚动</span>
    <div class="arrow"></div>
  </div>
</section>

<section class="demo-section">
  <p>页面其余内容区域<br>(模拟实际页面中的后续内容)</p>
</section>

<!-- GSAP + SplitText -->
<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/SplitText.min.js"></script>

<script>
document.addEventListener('DOMContentLoaded', () => {
  gsap.registerPlugin(SplitText);
  // ============================================
  // 子序列1:Hero背景视差(独立于Timeline)
  // ============================================
  const heroBg = document.querySelector('.hero-bg');

  document.addEventListener('mousemove', (e) => {
    const sensitivity = 0.03;
    const moveX = (e.clientX - window.innerWidth / 2) * sensitivity;
    const moveY = (e.clientY - window.innerHeight / 2) * sensitivity;

    gsap.to(heroBg, {
      x: moveX,
      y: moveY,
      duration: 1.2,
      ease: 'power2.out',
      overwrite: 'auto'    // 自动kill旧动画,从当前位置开始新动画
    });
  });

  // ============================================
  // 子序列2:文字逐字入场(主Timeline)
  // ============================================
  const titleEl = document.querySelector('.hero-title');
  const subtitleEl = document.querySelector('.hero-subtitle');
  const ctaBtn = document.querySelector('#ctaBtn');
  const scrollHint = document.querySelector('.scroll-hint');

  // SplitText拆分标题
  const titleSplit = new SplitText(titleEl, {
    type: 'chars,words',
    charsClass: 'char',
    wordsClass: 'word'
  });

  // 主入场Timeline
  const entranceTL = gsap.timeline({
    defaults: { ease: 'power3.out' },
    delay: 0.3            // 页面加载后稍稍延迟,给浏览器渲染时间
  });

  // 阶段1:标题逐字入场
  entranceTL.from(titleSplit.chars, {
    y: 80,
    opacity: 0,
    rotationX: -90,
    stagger: {
      each: 0.04,
      from: 'center'     // 从中间向两边展开
    },
    duration: 0.7,
    ease: 'back.out(1.5)'
  });

  // 阶段2:副标题淡入上移
  entranceTL.from(subtitleEl, {
    y: 40,
    opacity: 0,
    duration: 0.8,
    ease: 'power2.out'
  }, '-=0.4');

  // 阶段3:CTA按钮弹性弹出
  entranceTL.from(ctaBtn, {
    scale: 0,
    opacity: 0,
    duration: 0.9,
    ease: 'elastic.out(1, 0.5)'
  }, '-=0.3');

  // 阶段4:滚动提示淡入
  entranceTL.to(scrollHint, {
    opacity: 1,
    y: -10,
    duration: 0.8
  }, '-=0.2');

  // ============================================
  // 子序列3:CTA按钮hover微交互
  // ============================================
  const hoverTL = gsap.timeline({ paused: true });

  hoverTL.to(ctaBtn, {
    scale: 1.06,
    boxShadow: '0 0 30px rgba(108, 99, 255, 0.6), 0 10px 30px rgba(108, 99, 255, 0.3)',
    duration: 0.3,
    ease: 'power2.out'
  });

  ctaBtn.addEventListener('mouseenter', () => hoverTL.play());
  ctaBtn.addEventListener('mouseleave', () => hoverTL.reverse());

  // 点击按压反馈
  ctaBtn.addEventListener('mousedown', () => {
    gsap.to(ctaBtn, {
      scale: 0.95,
      duration: 0.1,
      ease: 'power2.in'
    });
  });

  ctaBtn.addEventListener('mouseup', () => {
    gsap.to(ctaBtn, {
      scale: 1.06,
      duration: 0.2,
      ease: 'elastic.out(1, 0.4)'
    });
  });

  ctaBtn.addEventListener('click', () => {
    // 点击波纹效果
    gsap.fromTo(ctaBtn, {
      boxShadow: '0 0 0 0 rgba(108, 99, 255, 0.8)'
    }, {
      boxShadow: '0 0 0 20px rgba(108, 99, 255, 0)',
      duration: 0.8,
      ease: 'power2.out'
    });
  });

});
</script>
</body>
</html>

10.7 技术解析

为何 hover 用独立 paused Timeline

const hoverTL = gsap.timeline({ paused: true });
// ...
btn.addEventListener('mouseenter', () => hoverTL.play());
btn.addEventListener('mouseleave', () => hoverTL.reverse());

这种方式有3个优势:

  1. 可回放——reverse() 让hover动画优雅回退,而不是突然跳回。
  2. 不被干扰——独立Timeline不会被主入场序列影响。
  3. 干净——hover离开时动画会精确从当前位置逆向执行,即使hover时间很短也不会有残留。

把hover的"进场"和"退场"打包成一支小动画,鼠标来了就正着放,走了就倒着放,丝滑无缝。

对比直接 gsap.to 方式:

// ❌ 不够优雅
btn.addEventListener('mouseenter', () => {
  gsap.to(btn, { scale: 1.06, duration: 0.3 });
});
btn.addEventListener('mouseleave', () => {
  gsap.to(btn, { scale: 1, duration: 0.3 });  // 手动写反方向
});

把这一节跑一遍:悬停、聚焦与按压反馈

悬停、聚焦与按压是三个状态,不应只给鼠标提供反馈。演示用同一状态逻辑处理 pointer 与键盘,显式 overwrite:auto 避免快速进出留下冲突动画。

实验:悬停、聚焦与按压反馈

GSAP 3.13 / 实验

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

观察与验证:用 Tab 聚焦按钮,再用空格按压;应与鼠标悬停/按压有对应反馈。移出或失焦后缩放回到 1。

为何 mousemove 不用 throttle

高频指针事件不要每次创建多组新 Tween。quickTo 为单个属性复用一个 Tween,适合鼠标视差:

const xTo = gsap.quickTo(layer, 'x', { duration:0.4, ease:'power2.out' });
function onPointerMove(event) {
  const bounds = area.getBoundingClientRect();
  xTo((event.clientX - bounds.left - bounds.width/2) * 0.1);
}
area.addEventListener('pointermove', onPointerMove);
// 卸载时移除监听,并 context.revert()。

是否节流要根据测量判断。如果每次事件都进行复杂几何计算,可用帧级合并减少工作;不能认为 GSAP 自动“去抖”所有输入。overwrite:auto 解决属性冲突,quickTo 解决频繁创建 Tween,这两者作用不同。

stagger: { from: 'center' }

stagger: {
  each: 0.04,
  from: 'center'     // 关键参数
}

from: 'center' 让stagger从中间开始向两边扩散。对于"重新定义生产力"这7个字:

  • 第4个字"生"首先动画
  • 然后"定"和"产"同时
  • 最后"重"和"力"同时

这比从左到右的stagger更有"展开感"。


10.8 常见陷阱

陷阱1:视差与ScrollTrigger冲突

如果页面后续使用了 ScrollTrigger,mousemove视差与scroll驱动动画同时操作同一元素时可能冲突。

症状:背景位置抖动、突变。

解决:

// 在ScrollTrigger动画中使用 overwrite: 'none' 或独立的transform属性
ScrollTrigger.create({
  trigger: '.hero',
  start: 'top top',
  onUpdate: (self) => {
    // 使用独立的CSS变量而不是直接修改transform
    gsap.set(heroBg, {
      '--scroll-y': self.progress * 100
    });
  }
});

// CSS中分开管理
// transform 归 mousemove 视差
// --scroll-y 归滚动效果

陷阱2:SplitText中文处理

SplitText 3.13 重写了字符拆分逻辑,改善了 emoji 和多语言字符处理,不能将它描述为简单的 textContent.split('')。

  • 标点和中英混排:按实际字体与容器宽度检查换行。type: 'chars,words' 不等于自动识别完整中文词组;仅拆字符时可使用 smartWrap: true 改善不自然的断词。
  • 组合 emoji:保留真实文本并检查拆分结果,不要先用 split('') 手动拆分,以免拆坏 Unicode 字符组合。
  • 字体与响应式:字体加载或宽度变化可能改变行布局。拆行时可使用 autoSplit 和 onSplit 管理重新拆分及对应动画,详见 SplitText 官方文档。

处理方案:

// 使用 SplitText 处理真实文本,包括需要保留的标点与空格

const split = new SplitText('.title', { type: 'chars' });
// 动画结束后还原
entranceTL.call(() => split.revert());

陷阱3:入场动画阻塞交互

如果Timeline太长或delay太大,用户在动画播放期间点击按钮将无响应。

症状:按钮已显示但点击无效(应检查 pointer-events、遮罩、disabled 或事件绑定;opacity 不会禁用点击)。

解决:

// 确保按钮在视觉上出现时即可交互
entranceTL.from(ctaBtn, {
  scale: 0,
  duration: 0.8,
  ease: 'elastic.out(1, 0.5)',
  onStart: () => {
    ctaBtn.style.pointerEvents = 'auto';   // 动画一开始就允许交互
  }
});
// CSS中默认设置 pointer-events: none,通过 onStart 激活

或更简单:不在 .from() 中操作 opacity,而是让元素一开始就是可见的,只动画 scale。