从补间到叙事:GSAP 动画实战 · 公开阅读
第10章 炫酷首页动画
组合 Hero 视差、文字入场与按钮反馈,构建首页动画。
本章小节
难度:★★☆☆☆ | 用到的GSAP能力:Timeline、SplitText、ScrollTrigger、基础动画属性 | 项目类型:产品首页Hero动画
10.1 需求分析
产品首页动画的需求往往很模糊——"这个首页要有冲击力"。Dribbble上的参考动不动三秒转场、文字逐个飞出。拆开来看,所谓的"冲击力"其实是三层:
- 视觉冲击——用户打开页面的前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个优势:
- 可回放——
reverse()让hover动画优雅回退,而不是突然跳回。 - 不被干扰——独立Timeline不会被主入场序列影响。
- 干净——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。