从补间到叙事: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开销小)