gsap-animation
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWhen to use
适用场景
Use this skill when creating Remotion video compositions that need GSAP's advanced animation capabilities beyond Remotion's built-in and .
interpolate()spring()Use GSAP when you need:
- Complex timeline orchestration (nesting, labels, position parameters like )
"-=0.5" - Text splitting animation (SplitText: chars/words/lines with mask reveals)
- SVG shape morphing (MorphSVG), stroke drawing (DrawSVG), path-following (MotionPath)
- Advanced easing (CustomEase from SVG paths, RoughEase, SlowMo, CustomBounce, CustomWiggle)
- Stagger with grid, center/edges distribution
- Character scramble/decode effects (ScrambleText)
- Reusable named effects via
gsap.registerEffect()
Use Remotion native when:
interpolate()- Simple single-property animations (fade, slide, scale) -- do NOT use GSAP for these
- Numeric counters/progress bars -- pure math, no timeline needed
- Standard easing curves
- Spring physics ()
spring()
GSAP Licensing: All plugins are 100% free since Webflow's 2024 acquisition (SplitText, MorphSVG, DrawSVG, etc.).
当你创建Remotion视频合成内容,且需要GSAP提供的、超出Remotion内置和能力的高级动画功能时,可使用本技能。
interpolate()spring()以下场景请使用GSAP:
- 复杂时间线编排(嵌套结构、标签、这类位置参数)
"-=0.5" - 文本拆分动画(SplitText:按字符/单词/行拆分,搭配遮罩显示效果)
- SVG形状变形(MorphSVG)、描边绘制(DrawSVG)、路径跟随(MotionPath)
- 高级缓动效果(基于SVG路径的CustomEase、RoughEase、SlowMo、CustomBounce、CustomWiggle)
- 网格、中心/边缘分布的交错动画(Stagger)
- 字符乱码/解码效果(ScrambleText)
- 通过实现的可复用命名效果
gsap.registerEffect()
以下场景请使用Remotion原生:
interpolate()- 简单的单属性动画(淡入淡出、滑动、缩放)——此类场景请勿使用GSAP
- 数字计数器/进度条——纯数学计算,无需时间线
- 标准缓动曲线
- 弹簧物理效果()
spring()
GSAP授权说明:自2024年Webflow收购GSAP后,所有插件(SplitText、MorphSVG、DrawSVG等)均100%免费。
Setup
配置步骤
bash
undefinedbash
undefinedIn a Remotion project
在Remotion项目中执行
npm install gsap
```tsx
// src/gsap-setup.ts -- import once at entry point
import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin';
import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
import { ScrambleTextPlugin } from 'gsap/ScrambleTextPlugin';
import { CustomEase } from 'gsap/CustomEase';
import { CustomBounce } from 'gsap/CustomBounce';
import { CustomWiggle } from 'gsap/CustomWiggle';
gsap.registerPlugin(
SplitText, MorphSVGPlugin, DrawSVGPlugin, MotionPathPlugin,
ScrambleTextPlugin, CustomEase, CustomBounce, CustomWiggle,
);
export { gsap };npm install gsap
```tsx
// src/gsap-setup.ts -- 在入口文件中导入一次
import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin';
import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
import { ScrambleTextPlugin } from 'gsap/ScrambleTextPlugin';
import { CustomEase } from 'gsap/CustomEase';
import { CustomBounce } from 'gsap/CustomBounce';
import { CustomWiggle } from 'gsap/CustomWiggle';
gsap.registerPlugin(
SplitText, MorphSVGPlugin, DrawSVGPlugin, MotionPathPlugin,
ScrambleTextPlugin, CustomEase, CustomBounce, CustomWiggle,
);
export { gsap };Core Hook: useGSAPTimeline
核心Hook:useGSAPTimeline
The bridge between GSAP and Remotion. Creates a paused timeline, seeks it to every frame.
frame / fpstsx
import { useCurrentFrame, useVideoConfig } from 'remotion';
import gsap from 'gsap';
import { useRef, useEffect } from 'react';
function useGSAPTimeline(
buildTimeline: (tl: gsap.core.Timeline, container: HTMLDivElement) => void
) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const containerRef = useRef<HTMLDivElement>(null);
const tlRef = useRef<gsap.core.Timeline | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline({ paused: true });
buildTimeline(tl, containerRef.current!);
tlRef.current = tl;
}, containerRef);
return () => { ctx.revert(); tlRef.current = null; };
}, []);
useEffect(() => {
if (tlRef.current) tlRef.current.seek(frame / fps);
}, [frame, fps]);
return containerRef;
}For SplitText (needs font loading):
tsx
import { delayRender, continueRender } from 'remotion';
function useGSAPWithFonts(
buildTimeline: (tl: gsap.core.Timeline, container: HTMLDivElement) => void
) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const containerRef = useRef<HTMLDivElement>(null);
const tlRef = useRef<gsap.core.Timeline | null>(null);
const [handle] = useState(() => delayRender());
useEffect(() => {
document.fonts.ready.then(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline({ paused: true });
buildTimeline(tl, containerRef.current!);
tlRef.current = tl;
}, containerRef);
continueRender(handle);
return () => { ctx.revert(); };
});
}, []);
useEffect(() => {
if (tlRef.current) tlRef.current.seek(frame / fps);
}, [frame, fps]);
return containerRef;
}作为GSAP与Remotion的桥梁,该Hook会创建一个暂停的时间线,并在每一帧将其定位到的位置。
frame / fpstsx
import { useCurrentFrame, useVideoConfig } from 'remotion';
import gsap from 'gsap';
import { useRef, useEffect } from 'react';
function useGSAPTimeline(
buildTimeline: (tl: gsap.core.Timeline, container: HTMLDivElement) => void
) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const containerRef = useRef<HTMLDivElement>(null);
const tlRef = useRef<gsap.core.Timeline | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline({ paused: true });
buildTimeline(tl, containerRef.current!);
tlRef.current = tl;
}, containerRef);
return () => { ctx.revert(); tlRef.current = null; };
}, []);
useEffect(() => {
if (tlRef.current) tlRef.current.seek(frame / fps);
}, [frame, fps]);
return containerRef;
}针对SplitText(需要等待字体加载)的版本:
tsx
import { delayRender, continueRender } from 'remotion';
function useGSAPWithFonts(
buildTimeline: (tl: gsap.core.Timeline, container: HTMLDivElement) => void
) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const containerRef = useRef<HTMLDivElement>(null);
const tlRef = useRef<gsap.core.Timeline | null>(null);
const [handle] = useState(() => delayRender());
useEffect(() => {
document.fonts.ready.then(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline({ paused: true });
buildTimeline(tl, containerRef.current!);
tlRef.current = tl;
}, containerRef);
continueRender(handle);
return () => { ctx.revert(); };
});
}, []);
useEffect(() => {
if (tlRef.current) tlRef.current.seek(frame / fps);
}, [frame, fps]);
return containerRef;
}1. Text Animations
1. 文本动画
SplitText Reveal (chars/words/lines)
SplitText 渐显效果(按字符/单词/行)
tsx
const TextReveal: React.FC<{ text: string }> = ({ text }) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const split = SplitText.create(container.querySelector('.heading')!, {
type: 'chars,words,lines', mask: 'lines',
});
tl.from(split.chars, {
y: 100, opacity: 0, duration: 0.6, stagger: 0.03, ease: 'power2.out',
});
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef}>
<h1 className="heading" style={{ fontSize: 80, fontWeight: 'bold' }}>{text}</h1>
</div>
</AbsoluteFill>
);
};Patterns:
| Pattern | SplitText Config | Animation |
|---|---|---|
| Line reveal | | |
| Char cascade | | |
| Word scale | | |
| Char + color | | |
tsx
const TextReveal: React.FC<{ text: string }> = ({ text }) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const split = SplitText.create(container.querySelector('.heading')!, {
type: 'chars,words,lines', mask: 'lines',
});
tl.from(split.chars, {
y: 100, opacity: 0, duration: 0.6, stagger: 0.03, ease: 'power2.out',
});
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef}>
<h1 className="heading" style={{ fontSize: 80, fontWeight: 'bold' }}>{text}</h1>
</div>
</AbsoluteFill>
);
};常见模式:
| 模式 | SplitText配置 | 动画效果 |
|---|---|---|
| 逐行渐显 | | |
| 字符瀑布流 | | |
| 单词缩放 | | |
| 字符+颜色变化 | | |
ScrambleText (decode effect)
ScrambleText(解码效果)
Determinism warning: ScrambleText uses internal random character selection. Usewhen rendering to guarantee frame-perfect reproducibility across renders.--concurrency=1
tsx
const containerRef = useGSAPTimeline((tl, container) => {
tl.to(container.querySelector('.text')!, {
duration: 2,
scrambleText: { text: 'DECODED', chars: '01', revealDelay: 0.5, speed: 0.3 },
});
});Char sets: , , , , or custom string.
"upperCase""lowerCase""upperAndLowerCase""01"**确定性提示:**ScrambleText使用内部随机字符选择逻辑。渲染时请使用参数,以确保多次渲染的帧内容完全一致。--concurrency=1
tsx
const containerRef = useGSAPTimeline((tl, container) => {
tl.to(container.querySelector('.text')!, {
duration: 2,
scrambleText: { text: 'DECODED', chars: '01', revealDelay: 0.5, speed: 0.3 },
});
});字符集选项:、、、,或自定义字符串。
"upperCase""lowerCase""upperAndLowerCase""01"Text Highlight Box
文本高亮框
Colored rectangles scale in behind specific words. Uses SplitText for word-level positioning, then absolutely-positioned boxes at lower z-index.
<div>tsx
const TextHighlightBox: React.FC<{
text: string;
highlights: Array<{ wordIndex: number; color: string }>;
highlightDelay?: number;
highlightStagger?: number;
}> = ({ text, highlights, highlightDelay = 0.5, highlightStagger = 0.3 }) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const textEl = container.querySelector('.highlight-text')!;
const split = SplitText.create(textEl, { type: 'words' });
// Entrance: words fade in
tl.from(split.words, {
y: 20, opacity: 0, duration: 0.5, stagger: 0.05, ease: 'power2.out',
});
// Highlight boxes scale in behind target words
highlights.forEach(({ wordIndex, color }, i) => {
const word = split.words[wordIndex] as HTMLElement;
if (!word) return;
const box = document.createElement('div');
Object.assign(box.style, {
position: 'absolute',
left: `${word.offsetLeft - 4}px`,
top: `${word.offsetTop - 2}px`,
width: `${word.offsetWidth + 8}px`,
height: `${word.offsetHeight + 4}px`,
background: color,
borderRadius: '4px',
zIndex: '-1',
transformOrigin: 'left center',
transform: 'scaleX(0)',
});
textEl.appendChild(box);
tl.to(box, {
scaleX: 1, duration: 0.3, ease: 'power2.out',
}, highlightDelay + i * highlightStagger);
});
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef}>
<p className="highlight-text" style={{
fontSize: 64, fontWeight: 'bold', color: '#fff',
position: 'relative', maxWidth: '70%', lineHeight: 1.2,
}}>{text}</p>
</div>
</AbsoluteFill>
);
};Props: is an array of targeting specific words (0-indexed from SplitText).
highlights{ wordIndex, color }在指定单词后方显示彩色矩形缩放效果。通过SplitText获取单词位置,再使用绝对定位的作为高亮框(置于底层)。
<div>tsx
const TextHighlightBox: React.FC<{
text: string;
highlights: Array<{ wordIndex: number; color: string }>;
highlightDelay?: number;
highlightStagger?: number;
}> = ({ text, highlights, highlightDelay = 0.5, highlightStagger = 0.3 }) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const textEl = container.querySelector('.highlight-text')!;
const split = SplitText.create(textEl, { type: 'words' });
// 入场:单词淡入
tl.from(split.words, {
y: 20, opacity: 0, duration: 0.5, stagger: 0.05, ease: 'power2.out',
});
// 高亮框在目标单词后方缩放显示
highlights.forEach(({ wordIndex, color }, i) => {
const word = split.words[wordIndex] as HTMLElement;
if (!word) return;
const box = document.createElement('div');
Object.assign(box.style, {
position: 'absolute',
left: `${word.offsetLeft - 4}px`,
top: `${word.offsetTop - 2}px`,
width: `${word.offsetWidth + 8}px`,
height: `${word.offsetHeight + 4}px`,
background: color,
borderRadius: '4px',
zIndex: '-1',
transformOrigin: 'left center',
transform: 'scaleX(0)',
});
textEl.appendChild(box);
tl.to(box, {
scaleX: 1, duration: 0.3, ease: 'power2.out',
}, highlightDelay + i * highlightStagger);
});
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef}>
<p className="highlight-text" style={{
fontSize: 64, fontWeight: 'bold', color: '#fff',
position: 'relative', maxWidth: '70%', lineHeight: 1.2,
}}>{text}</p>
</div>
</AbsoluteFill>
);
};属性说明:是一个包含的数组,用于指定要高亮的单词(基于SplitText的0索引)。
highlights{ wordIndex, color }2. SVG Animations
2. SVG动画
MorphSVG (shape morphing)
MorphSVG(形状变形)
tsx
const containerRef = useGSAPTimeline((tl, container) => {
tl.to(container.querySelector('#path')!, {
morphSVG: { shape: '#target-path', type: 'rotational', map: 'size' },
duration: 1.5, ease: 'power2.inOut',
});
});| Option | Values |
|---|---|
| |
| |
| Integer for point alignment offset |
tsx
const containerRef = useGSAPTimeline((tl, container) => {
tl.to(container.querySelector('#path')!, {
morphSVG: { shape: '#target-path', type: 'rotational', map: 'size' },
duration: 1.5, ease: 'power2.inOut',
});
});| 选项 | 可选值 |
|---|---|
| |
| |
| 整数,用于设置点对齐的偏移量 |
DrawSVG (stroke animation)
DrawSVG(描边动画)
tsx
const containerRef = useGSAPTimeline((tl, container) => {
const paths = container.querySelectorAll('.logo-path');
tl.from(paths, { drawSVG: 0, duration: 1.5, stagger: 0.2, ease: 'power2.inOut' })
.to(paths, { fill: '#ffffff', duration: 0.5 }, '-=0.3');
});| Pattern | DrawSVG Value |
|---|---|
| Draw from nothing | |
| Draw from center | |
| Show segment | |
| Erase | |
tsx
const containerRef = useGSAPTimeline((tl, container) => {
const paths = container.querySelectorAll('.logo-path');
tl.from(paths, { drawSVG: 0, duration: 1.5, stagger: 0.2, ease: 'power2.inOut' })
.to(paths, { fill: '#ffffff', duration: 0.5 }, '-=0.3');
});| 模式 | DrawSVG参数值 |
|---|---|
| 从无到有绘制 | |
| 从中心向两侧绘制 | |
| 显示指定线段 | |
| 擦除效果 | |
MotionPath (path following)
MotionPath(路径跟随)
tsx
const containerRef = useGSAPTimeline((tl, container) => {
tl.to(container.querySelector('.element')!, {
motionPath: {
path: container.querySelector('#svg-path') as SVGPathElement,
align: container.querySelector('#svg-path') as SVGPathElement,
alignOrigin: [0.5, 0.5],
autoRotate: true,
},
duration: 3, ease: 'power1.inOut',
});
});tsx
const containerRef = useGSAPTimeline((tl, container) => {
tl.to(container.querySelector('.element')!, {
motionPath: {
path: container.querySelector('#svg-path') as SVGPathElement,
align: container.querySelector('#svg-path') as SVGPathElement,
alignOrigin: [0.5, 0.5],
autoRotate: true,
},
duration: 3, ease: 'power1.inOut',
});
});3. 3D Transform Patterns
3. 3D变换模式
Performance note: Limit to 3-4 simultaneous 3D containers per scene. Eachcontainer triggers GPU compositing layers.preserve-3d
**性能提示:**每个场景中同时存在的3D容器请控制在3-4个以内。每个容器都会触发GPU合成层。preserve-3d
CardFlip3D
3D卡片翻转
tsx
const CardFlip3D: React.FC<{
frontContent: React.ReactNode;
backContent: React.ReactNode;
flipDelay?: number;
flipDuration?: number;
}> = ({ frontContent, backContent, flipDelay = 0.5, flipDuration = 1.2 }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const card = container.querySelector('.card-3d')!;
tl.to(card, {
rotateY: 180, duration: flipDuration, ease: 'power2.inOut',
}, flipDelay);
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef} style={{ perspective: 800 }}>
<div className="card-3d" style={{
width: 500, height: 320, position: 'relative', transformStyle: 'preserve-3d',
}}>
{/* Front face */}
<div style={{
position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
background: '#1e293b', borderRadius: 16, display: 'flex',
alignItems: 'center', justifyContent: 'center', padding: 32,
}}>{frontContent}</div>
{/* Back face (pre-rotated 180deg) */}
<div style={{
position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
background: '#3b82f6', borderRadius: 16, transform: 'rotateY(180deg)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32,
}}>{backContent}</div>
</div>
</div>
</AbsoluteFill>
);
};tsx
const CardFlip3D: React.FC<{
frontContent: React.ReactNode;
backContent: React.ReactNode;
flipDelay?: number;
flipDuration?: number;
}> = ({ frontContent, backContent, flipDelay = 0.5, flipDuration = 1.2 }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const card = container.querySelector('.card-3d')!;
tl.to(card, {
rotateY: 180, duration: flipDuration, ease: 'power2.inOut',
}, flipDelay);
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef} style={{ perspective: 800 }}>
<div className="card-3d" style={{
width: 500, height: 320, position: 'relative', transformStyle: 'preserve-3d',
}}>
{/* 正面 */}
<div style={{
position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
background: '#1e293b', borderRadius: 16, display: 'flex',
alignItems: 'center', justifyContent: 'center', padding: 32,
}}>{frontContent}</div>
{/* 背面(预先旋转180度) */}
<div style={{
position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
background: '#3b82f6', borderRadius: 16, transform: 'rotateY(180deg)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32,
}}>{backContent}</div>
</div>
</div>
</AbsoluteFill>
);
};PerspectiveEntrance
透视入场效果
Two elements enter from opposite sides with rotateY, converging to center.
tsx
const PerspectiveEntrance: React.FC<{
leftContent: React.ReactNode;
rightContent: React.ReactNode;
}> = ({ leftContent, rightContent }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const left = container.querySelector('.pe-left')!;
const right = container.querySelector('.pe-right')!;
tl.from(left, { x: -600, rotateY: 60, opacity: 0, duration: 0.8, ease: 'power3.out' })
.from(right, { x: 600, rotateY: -60, opacity: 0, duration: 0.8, ease: 'power3.out' }, '-=0.5')
.to(left, { rotateY: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3')
.to(right, { rotateY: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3');
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 800 }}>
<div ref={containerRef} style={{ display: 'flex', gap: 40, alignItems: 'center' }}>
<div className="pe-left">{leftContent}</div>
<div className="pe-right">{rightContent}</div>
</div>
</AbsoluteFill>
);
};两个元素从相反方向带着rotateY效果入场,最终汇聚到中心。
tsx
const PerspectiveEntrance: React.FC<{
leftContent: React.ReactNode;
rightContent: React.ReactNode;
}> = ({ leftContent, rightContent }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const left = container.querySelector('.pe-left')!;
const right = container.querySelector('.pe-right')!;
tl.from(left, { x: -600, rotateY: 60, opacity: 0, duration: 0.8, ease: 'power3.out' })
.from(right, { x: 600, rotateY: -60, opacity: 0, duration: 0.8, ease: 'power3.out' }, '-=0.5')
.to(left, { rotateY: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3')
.to(right, { rotateY: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3');
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 800 }}>
<div ref={containerRef} style={{ display: 'flex', gap: 40, alignItems: 'center' }}>
<div className="pe-left">{leftContent}</div>
<div className="pe-right">{rightContent}</div>
</div>
</AbsoluteFill>
);
};RotateXTextSwap
旋转X轴文本切换
Outgoing text tilts backward, incoming text falls forward. Uses to pivot from the correct edge.
transformOrigintsx
const RotateXTextSwap: React.FC<{
textOut: string;
textIn: string;
swapDelay?: number;
}> = ({ textOut, textIn, swapDelay = 1.0 }) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const outEl = container.querySelector('.swap-out')!;
const inEl = container.querySelector('.swap-in')!;
// Out: tilt backward
tl.to(outEl, {
rotateX: 90, opacity: 0, duration: 0.5,
transformOrigin: 'center bottom', ease: 'power2.in',
}, swapDelay);
// In: fall forward
tl.fromTo(inEl,
{ rotateX: -90, opacity: 0, transformOrigin: 'center top' },
{ rotateX: 0, opacity: 1, duration: 0.6, ease: 'power2.out' },
`>${-0.15}`
);
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 600 }}>
<div ref={containerRef} style={{ position: 'relative', textAlign: 'center' }}>
<h1 className="swap-out" style={{ fontSize: 80, fontWeight: 'bold', color: '#fff' }}>{textOut}</h1>
<h1 className="swap-in" style={{
fontSize: 80, fontWeight: 'bold', color: '#3b82f6',
position: 'absolute', inset: 0,
}}>{textIn}</h1>
</div>
</AbsoluteFill>
);
};退场文本向后倾斜,入场文本向前落下。使用设置正确的旋转支点。
transformOrigintsx
const RotateXTextSwap: React.FC<{
textOut: string;
textIn: string;
swapDelay?: number;
}> = ({ textOut, textIn, swapDelay = 1.0 }) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const outEl = container.querySelector('.swap-out')!;
const inEl = container.querySelector('.swap-in')!;
// 退场:向后倾斜
tl.to(outEl, {
rotateX: 90, opacity: 0, duration: 0.5,
transformOrigin: 'center bottom', ease: 'power2.in',
}, swapDelay);
// 入场:向前落下
tl.fromTo(inEl,
{ rotateX: -90, opacity: 0, transformOrigin: 'center top' },
{ rotateX: 0, opacity: 1, duration: 0.6, ease: 'power2.out' },
`>${-0.15}`
);
});
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 600 }}>
<div ref={containerRef} style={{ position: 'relative', textAlign: 'center' }}>
<h1 className="swap-out" style={{ fontSize: 80, fontWeight: 'bold', color: '#fff' }}>{textOut}</h1>
<h1 className="swap-in" style={{
fontSize: 80, fontWeight: 'bold', color: '#3b82f6',
position: 'absolute', inset: 0,
}}>{textIn}</h1>
</div>
</AbsoluteFill>
);
};4. Interaction Simulation
4. 交互模拟
CursorClick
光标点击效果
Simulates a cursor navigating to a target and clicking. Cursor slides in, target depresses, ripple expands.
tsx
const CursorClick: React.FC<{
targetSelector: string;
cursorDelay?: number;
clickDelay?: number;
children: React.ReactNode;
}> = ({ targetSelector, cursorDelay = 0.3, clickDelay = 0.8, children }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const target = container.querySelector(targetSelector)!;
const cursor = container.querySelector('.sim-cursor')!;
const ripple = container.querySelector('.sim-ripple')!;
const rect = target.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const targetX = rect.left - containerRect.left + rect.width / 2;
const targetY = rect.top - containerRect.top + rect.height / 2;
// Cursor travels to target
tl.fromTo(cursor,
{ x: containerRect.width + 40, y: targetY - 20 },
{ x: targetX, y: targetY, duration: clickDelay, ease: 'power2.inOut' },
cursorDelay
);
// Click: target depresses and releases
tl.to(target, { scale: 0.95, duration: 0.1, ease: 'power2.in' })
.to(target, { scale: 1, duration: 0.15, ease: 'power2.out' });
// Ripple expands (overlaps with click release)
tl.fromTo(ripple,
{ x: targetX, y: targetY, scale: 0, opacity: 1 },
{ scale: 3, opacity: 0, duration: 0.6, ease: 'power2.out' },
'<-0.1'
);
});
return (
<AbsoluteFill>
<div ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
{children}
{/* Cursor */}
<svg className="sim-cursor" width="24" height="24" viewBox="0 0 24 24"
style={{ position: 'absolute', top: 0, left: 0, zIndex: 100, pointerEvents: 'none' }}>
<path d="M5 3l14 8-6 2-4 6z" fill="#fff" stroke="#000" strokeWidth="1.5" />
</svg>
{/* Ripple */}
<div className="sim-ripple" style={{
position: 'absolute', top: 0, left: 0, width: 40, height: 40,
borderRadius: '50%', border: '2px solid rgba(59,130,246,0.6)',
transform: 'translate(-50%, -50%) scale(0)', pointerEvents: 'none',
}} />
</div>
</AbsoluteFill>
);
};Props: is a CSS selector for the element to "click" within the container. Cursor enters from off-screen right.
targetSelector模拟光标移动到目标元素并点击的过程:光标滑入、目标元素按下、波纹扩散。
tsx
const CursorClick: React.FC<{
targetSelector: string;
cursorDelay?: number;
clickDelay?: number;
children: React.ReactNode;
}> = ({ targetSelector, cursorDelay = 0.3, clickDelay = 0.8, children }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const target = container.querySelector(targetSelector)!;
const cursor = container.querySelector('.sim-cursor')!;
const ripple = container.querySelector('.sim-ripple')!;
const rect = target.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const targetX = rect.left - containerRect.left + rect.width / 2;
const targetY = rect.top - containerRect.top + rect.height / 2;
// 光标移动到目标位置
tl.fromTo(cursor,
{ x: containerRect.width + 40, y: targetY - 20 },
{ x: targetX, y: targetY, duration: clickDelay, ease: 'power2.inOut' },
cursorDelay
);
// 点击:目标元素按下并回弹
tl.to(target, { scale: 0.95, duration: 0.1, ease: 'power2.in' })
.to(target, { scale: 1, duration: 0.15, ease: 'power2.out' });
// 波纹扩散(与点击回弹重叠)
tl.fromTo(ripple,
{ x: targetX, y: targetY, scale: 0, opacity: 1 },
{ scale: 3, opacity: 0, duration: 0.6, ease: 'power2.out' },
'<-0.1'
);
});
return (
<AbsoluteFill>
<div ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
{children}
{/* 光标 */}
<svg className="sim-cursor" width="24" height="24" viewBox="0 0 24 24"
style={{ position: 'absolute', top: 0, left: 0, zIndex: 100, pointerEvents: 'none' }}>
<path d="M5 3l14 8-6 2-4 6z" fill="#fff" stroke="#000" strokeWidth="1.5" />
</svg>
{/* 波纹 */}
<div className="sim-ripple" style={{
position: 'absolute', top: 0, left: 0, width: 40, height: 40,
borderRadius: '50%', border: '2px solid rgba(59,130,246,0.6)',
transform: 'translate(-50%, -50%) scale(0)', pointerEvents: 'none',
}} />
</div>
</AbsoluteFill>
);
};属性说明:是容器内要“点击”的元素的CSS选择器。光标从屏幕右侧外滑入。
targetSelector5. Transitions
5. 转场效果
Clip-Path Transitions
裁剪路径转场
Performance note: Complex clip-path animations (especially) can slow down frame generation in Remotion's headless Chrome. If render times are high, consider replacing with opacity/transform-based alternatives or simplify topolygon/circleshapes.inset
tsx
const containerRef = useGSAPTimeline((tl, container) => {
const scene = container.querySelector('.scene')!;
// Circle reveal
tl.fromTo(scene,
{ clipPath: 'circle(0% at 50% 50%)' },
{ clipPath: 'circle(75% at 50% 50%)', duration: 1, ease: 'power2.out' }
);
});| Transition | From | To |
|---|---|---|
| Circle reveal | | |
| Wipe left | | |
| Iris | | |
| Blinds | | |
**性能提示:**复杂的clip-path动画(尤其是类型)可能会减慢Remotion无头Chrome的帧生成速度。如果渲染时间过长,考虑替换为基于透明度/变换的转场,或简化为polygon/circle形状。inset
tsx
const containerRef = useGSAPTimeline((tl, container) => {
const scene = container.querySelector('.scene')!;
// 圆形渐显
tl.fromTo(scene,
{ clipPath: 'circle(0% at 50% 50%)' },
{ clipPath: 'circle(75% at 50% 50%)', duration: 1, ease: 'power2.out' }
);
});| 转场效果 | 起始值 | 结束值 |
|---|---|---|
| 圆形渐显 | | |
| 向左擦除 | | |
| 光圈效果 | | |
| 百叶窗 | | |
Slide / Crossfade
滑动/交叉淡入淡出
tsx
// Slide transition
tl.to(outgoing, { x: '-100%', duration: 0.6, ease: 'power2.inOut' })
.fromTo(incoming, { x: '100%' }, { x: '0%', duration: 0.6, ease: 'power2.inOut' }, 0);
// Crossfade
tl.to(outgoing, { opacity: 0, duration: 1 })
.fromTo(incoming, { opacity: 0 }, { opacity: 1, duration: 1 }, 0);tsx
// 滑动转场
tl.to(outgoing, { x: '-100%', duration: 0.6, ease: 'power2.inOut' })
.fromTo(incoming, { x: '100%' }, { x: '0%', duration: 0.6, ease: 'power2.inOut' }, 0);
// 交叉淡入淡出
tl.to(outgoing, { opacity: 0, duration: 1 })
.fromTo(incoming, { opacity: 0 }, { opacity: 1, duration: 1 }, 0);6. Templates
6. 模板
Lower Third
下三分之一字幕条
tsx
const LowerThird: React.FC<{ name: string; title: string; hold?: number }> = ({
name, title, hold = 4,
}) => {
const containerRef = useGSAPTimeline((tl, container) => {
const bar = container.querySelector('.lt-bar')!;
const nameEl = container.querySelector('.lt-name')!;
const titleEl = container.querySelector('.lt-title')!;
// In
tl.fromTo(bar, { scaleX: 0, transformOrigin: 'left' }, { scaleX: 1, duration: 0.4, ease: 'power2.out' })
.from(nameEl, { x: -30, opacity: 0, duration: 0.3 }, '-=0.1')
.from(titleEl, { x: -20, opacity: 0, duration: 0.3 }, '-=0.1')
// Hold
.to({}, { duration: hold })
// Out
.to([bar, nameEl, titleEl], { x: -50, opacity: 0, duration: 0.3, stagger: 0.05, ease: 'power2.in' });
});
return (
<AbsoluteFill>
<div ref={containerRef} style={{ position: 'absolute', bottom: 80, left: 60 }}>
<div className="lt-bar" style={{ background: '#3b82f6', padding: '12px 24px', borderRadius: 4 }}>
<div className="lt-name" style={{ fontSize: 28, fontWeight: 'bold', color: '#fff' }}>{name}</div>
<div className="lt-title" style={{ fontSize: 18, color: 'rgba(255,255,255,0.8)' }}>{title}</div>
</div>
</div>
</AbsoluteFill>
);
};tsx
const LowerThird: React.FC<{ name: string; title: string; hold?: number }> = ({
name, title, hold = 4,
}) => {
const containerRef = useGSAPTimeline((tl, container) => {
const bar = container.querySelector('.lt-bar')!;
const nameEl = container.querySelector('.lt-name')!;
const titleEl = container.querySelector('.lt-title')!;
// 入场
tl.fromTo(bar, { scaleX: 0, transformOrigin: 'left' }, { scaleX: 1, duration: 0.4, ease: 'power2.out' })
.from(nameEl, { x: -30, opacity: 0, duration: 0.3 }, '-=0.1')
.from(titleEl, { x: -20, opacity: 0, duration: 0.3 }, '-=0.1')
// 停留
.to({}, { duration: hold })
// 退场
.to([bar, nameEl, titleEl], { x: -50, opacity: 0, duration: 0.3, stagger: 0.05, ease: 'power2.in' });
});
return (
<AbsoluteFill>
<div ref={containerRef} style={{ position: 'absolute', bottom: 80, left: 60 }}>
<div className="lt-bar" style={{ background: '#3b82f6', padding: '12px 24px', borderRadius: 4 }}>
<div className="lt-name" style={{ fontSize: 28, fontWeight: 'bold', color: '#fff' }}>{name}</div>
<div className="lt-title" style={{ fontSize: 18, color: 'rgba(255,255,255,0.8)' }}>{title}</div>
</div>
</div>
</AbsoluteFill>
);
};Title Card
标题卡片
tsx
const TitleCard: React.FC<{ mainTitle: string; subtitle?: string }> = ({ mainTitle, subtitle }) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const bgShape = container.querySelector('.bg-shape')!;
const titleEl = container.querySelector('.main-title')!;
const divider = container.querySelector('.divider')!;
tl.from(bgShape, { scale: 0, rotation: -45, duration: 0.8, ease: 'back.out(1.7)' });
const split = SplitText.create(titleEl, { type: 'chars', mask: 'chars' });
tl.from(split.chars, { y: '100%', duration: 0.5, stagger: 0.03, ease: 'power3.out' }, '-=0.3')
.from('.subtitle', { y: 20, opacity: 0, duration: 0.6 }, '-=0.2')
.from(divider, { scaleX: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3');
});
return (
<AbsoluteFill style={{ background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef} style={{ textAlign: 'center', color: '#fff' }}>
<div className="bg-shape" style={{ position: 'absolute', width: 200, height: 200, borderRadius: '50%', background: 'rgba(59,130,246,0.2)', top: '30%', left: '45%' }} />
<h1 className="main-title" style={{ fontSize: 80, fontWeight: 'bold', position: 'relative' }}>{mainTitle}</h1>
<div className="divider" style={{ width: 80, height: 3, background: '#3b82f6', margin: '20px auto' }} />
{subtitle && <p className="subtitle" style={{ fontSize: 32, opacity: 0.8 }}>{subtitle}</p>}
</div>
</AbsoluteFill>
);
};tsx
const TitleCard: React.FC<{ mainTitle: string; subtitle?: string }> = ({ mainTitle, subtitle }) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const bgShape = container.querySelector('.bg-shape')!;
const titleEl = container.querySelector('.main-title')!;
const divider = container.querySelector('.divider')!;
tl.from(bgShape, { scale: 0, rotation: -45, duration: 0.8, ease: 'back.out(1.7)' });
const split = SplitText.create(titleEl, { type: 'chars', mask: 'chars' });
tl.from(split.chars, { y: '100%', duration: 0.5, stagger: 0.03, ease: 'power3.out' }, '-=0.3')
.from('.subtitle', { y: 20, opacity: 0, duration: 0.6 }, '-=0.2')
.from(divider, { scaleX: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3');
});
return (
<AbsoluteFill style={{ background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef} style={{ textAlign: 'center', color: '#fff' }}>
<div className="bg-shape" style={{ position: 'absolute', width: 200, height: 200, borderRadius: '50%', background: 'rgba(59,130,246,0.2)', top: '30%', left: '45%' }} />
<h1 className="main-title" style={{ fontSize: 80, fontWeight: 'bold', position: 'relative' }}>{mainTitle}</h1>
<div className="divider" style={{ width: 80, height: 3, background: '#3b82f6', margin: '20px auto' }} />
{subtitle && <p className="subtitle" style={{ fontSize: 32, opacity: 0.8 }}>{subtitle}</p>}
</div>
</AbsoluteFill>
);
};Logo Reveal (DrawSVG)
Logo描边渐显(基于DrawSVG)
tsx
const LogoReveal: React.FC<{ svgContent: React.ReactNode; text?: string }> = ({ svgContent, text }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const paths = container.querySelectorAll('.logo-path');
tl.from(paths, { drawSVG: 0, duration: 1.5, stagger: 0.1, ease: 'power2.inOut' })
.to(paths, { fill: '#fff', duration: 0.5 }, '-=0.3');
if (text) {
tl.from(container.querySelector('.logo-text')!, { opacity: 0, x: -20, duration: 0.5 }, '-=0.2');
}
});
return (
<AbsoluteFill style={{ background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef} style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
<svg viewBox="0 0 100 100" width={120} height={120}>{svgContent}</svg>
{text && <span className="logo-text" style={{ fontSize: 48, color: '#fff', fontWeight: 'bold' }}>{text}</span>}
</div>
</AbsoluteFill>
);
};tsx
const LogoReveal: React.FC<{ svgContent: React.ReactNode; text?: string }> = ({ svgContent, text }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const paths = container.querySelectorAll('.logo-path');
tl.from(paths, { drawSVG: 0, duration: 1.5, stagger: 0.1, ease: 'power2.inOut' })
.to(paths, { fill: '#fff', duration: 0.5 }, '-=0.3');
if (text) {
tl.from(container.querySelector('.logo-text')!, { opacity: 0, x: -20, duration: 0.5 }, '-=0.2');
}
});
return (
<AbsoluteFill style={{ background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef} style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
<svg viewBox="0 0 100 100" width={120} height={120}>{svgContent}</svg>
{text && <span className="logo-text" style={{ fontSize: 48, color: '#fff', fontWeight: 'bold' }}>{text}</span>}
</div>
</AbsoluteFill>
);
};Animated Counter
动画计数器
Uses Remotion's for deterministic frame-by-frame calculation. No GSAP needed — counters are pure math.
interpolate()tsx
import { interpolate, useCurrentFrame, useVideoConfig } from 'remotion';
const AnimatedCounter: React.FC<{
endValue: number; prefix?: string; suffix?: string; durationInSeconds?: number;
}> = ({ endValue, prefix = '', suffix = '', durationInSeconds = 2 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const value = interpolate(frame, [0, durationInSeconds * fps], [0, endValue], {
extrapolateRight: 'clamp',
easing: (t) => 1 - Math.pow(1 - t, 2), // power1.out equivalent
});
return (
<div style={{ fontSize: 96, fontWeight: 'bold', fontVariantNumeric: 'tabular-nums' }}>
{prefix}{Math.round(value).toLocaleString()}{suffix}
</div>
);
};使用Remotion的实现逐帧确定性计算。无需GSAP——计数器是纯数学逻辑。
interpolate()tsx
import { interpolate, useCurrentFrame, useVideoConfig } from 'remotion';
const AnimatedCounter: React.FC<{
endValue: number; prefix?: string; suffix?: string; durationInSeconds?: number;
}> = ({ endValue, prefix = '', suffix = '', durationInSeconds = 2 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const value = interpolate(frame, [0, durationInSeconds * fps], [0, endValue], {
extrapolateRight: 'clamp',
easing: (t) => 1 - Math.pow(1 - t, 2), // 等价于power1.out
});
return (
<div style={{ fontSize: 96, fontWeight: 'bold', fontVariantNumeric: 'tabular-nums' }}>
{prefix}{Math.round(value).toLocaleString()}{suffix}
</div>
);
};Outro (closing scene)
片尾场景
tsx
const Outro: React.FC<{ headline: string; tagline?: string; logoSvg?: React.ReactNode }> = ({
headline, tagline, logoSvg,
}) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const headlineEl = container.querySelector('.outro-headline')!;
const split = SplitText.create(headlineEl, { type: 'chars', mask: 'chars' });
tl.from(split.chars, { y: '100%', duration: 0.5, stagger: 0.03, ease: 'power3.out' });
if (tagline) {
tl.from('.outro-tagline', { opacity: 0, y: 15, duration: 0.5, ease: 'power2.out' }, '-=0.2');
}
if (logoSvg) {
const paths = container.querySelectorAll('.outro-logo path');
tl.from(paths, { drawSVG: 0, duration: 1.2, stagger: 0.08, ease: 'power2.inOut' }, '-=0.3')
.to(paths, { fill: '#fff', duration: 0.4 }, '-=0.2');
}
});
return (
<AbsoluteFill style={{ background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef} style={{ textAlign: 'center', color: '#fff' }}>
<h1 className="outro-headline" style={{ fontSize: 72, fontWeight: 'bold' }}>{headline}</h1>
{tagline && <p className="outro-tagline" style={{ fontSize: 28, opacity: 0.7, marginTop: 16 }}>{tagline}</p>}
{logoSvg && <div className="outro-logo" style={{ marginTop: 40 }}>{logoSvg}</div>}
</div>
</AbsoluteFill>
);
};tsx
const Outro: React.FC<{ headline: string; tagline?: string; logoSvg?: React.ReactNode }> = ({
headline, tagline, logoSvg,
}) => {
const containerRef = useGSAPWithFonts((tl, container) => {
const headlineEl = container.querySelector('.outro-headline')!;
const split = SplitText.create(headlineEl, { type: 'chars', mask: 'chars' });
tl.from(split.chars, { y: '100%', duration: 0.5, stagger: 0.03, ease: 'power3.out' });
if (tagline) {
tl.from('.outro-tagline', { opacity: 0, y: 15, duration: 0.5, ease: 'power2.out' }, '-=0.2');
}
if (logoSvg) {
const paths = container.querySelectorAll('.outro-logo path');
tl.from(paths, { drawSVG: 0, duration: 1.2, stagger: 0.08, ease: 'power2.inOut' }, '-=0.3')
.to(paths, { fill: '#fff', duration: 0.4 }, '-=0.2');
}
});
return (
<AbsoluteFill style={{ background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div ref={containerRef} style={{ textAlign: 'center', color: '#fff' }}>
<h1 className="outro-headline" style={{ fontSize: 72, fontWeight: 'bold' }}>{headline}</h1>
{tagline && <p className="outro-tagline" style={{ fontSize: 28, opacity: 0.7, marginTop: 16 }}>{tagline}</p>}
{logoSvg && <div className="outro-logo" style={{ marginTop: 40 }}>{logoSvg}</div>}
</div>
</AbsoluteFill>
);
};SplitScreenComparison
分屏对比
Two panels side-by-side with staggered entrance, optional center badge, and left-panel dim effect.
tsx
const SplitScreenComparison: React.FC<{
leftPanel: React.ReactNode;
rightPanel: React.ReactNode;
leftLabel?: string;
rightLabel?: string;
centerElement?: React.ReactNode;
dimLeft?: boolean;
hold?: number;
}> = ({ leftPanel, rightPanel, leftLabel, rightLabel, centerElement, dimLeft = false, hold = 2 }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const left = container.querySelector('.ssc-left')!;
const right = container.querySelector('.ssc-right')!;
const badge = container.querySelector('.ssc-badge');
// Staggered entrance
tl.from(left, { x: -80, opacity: 0, duration: 0.6, ease: 'power2.out' })
.from(right, { x: 80, opacity: 0, duration: 0.6, ease: 'power2.out' }, '-=0.4');
// Center badge pop
if (badge) {
tl.from(badge, { scale: 0, duration: 0.4, ease: 'back.out(2)' }, '-=0.2');
}
// Hold
tl.to({}, { duration: hold });
// Dim left, pop right (comparison effect)
if (dimLeft) {
tl.to(left, { opacity: 0.5, filter: 'blur(4px)', duration: 0.5, ease: 'power2.inOut' })
.to(right, { scale: 1.02, duration: 0.5, ease: 'power2.out' }, '<');
}
});
return (
<AbsoluteFill style={{ display: 'flex' }}>
<div ref={containerRef} style={{ display: 'flex', width: '100%', height: '100%' }}>
<div className="ssc-left" style={{
flex: 1, background: '#1e1e2e', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: 40, position: 'relative',
}}>
{leftLabel && <div style={{ fontSize: 24, opacity: 0.6, marginBottom: 16, color: '#fff' }}>{leftLabel}</div>}
{leftPanel}
</div>
{centerElement && (
<div className="ssc-badge" style={{
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
zIndex: 10, background: '#3b82f6', borderRadius: '50%', width: 60, height: 60,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, fontWeight: 'bold', color: '#fff',
}}>{centerElement}</div>
)}
<div className="ssc-right" style={{
flex: 1, background: 'rgba(255,255,255,0.06)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', padding: 40,
}}>
{rightLabel && <div style={{ fontSize: 24, opacity: 0.6, marginBottom: 16, color: '#fff' }}>{rightLabel}</div>}
{rightPanel}
</div>
</div>
</AbsoluteFill>
);
};Props: Set for comparison scenes where right panel should "win". Left panel uses dark background (), right panel uses glassmorphism ().
dimLeft: true#1e1e2ebackdropFilter: blur(20px)两个面板并排显示,交错入场,可添加中心徽章,左侧面板可设置变暗效果。
tsx
const SplitScreenComparison: React.FC<{
leftPanel: React.ReactNode;
rightPanel: React.ReactNode;
leftLabel?: string;
rightLabel?: string;
centerElement?: React.ReactNode;
dimLeft?: boolean;
hold?: number;
}> = ({ leftPanel, rightPanel, leftLabel, rightLabel, centerElement, dimLeft = false, hold = 2 }) => {
const containerRef = useGSAPTimeline((tl, container) => {
const left = container.querySelector('.ssc-left')!;
const right = container.querySelector('.ssc-right')!;
const badge = container.querySelector('.ssc-badge');
// 交错入场
tl.from(left, { x: -80, opacity: 0, duration: 0.6, ease: 'power2.out' })
.from(right, { x: 80, opacity: 0, duration: 0.6, ease: 'power2.out' }, '-=0.4');
// 中心徽章弹出
if (badge) {
tl.from(badge, { scale: 0, duration: 0.4, ease: 'back.out(2)' }, '-=0.2');
}
// 停留
tl.to({}, { duration: hold });
// 左侧变暗,右侧突出(对比效果)
if (dimLeft) {
tl.to(left, { opacity: 0.5, filter: 'blur(4px)', duration: 0.5, ease: 'power2.inOut' })
.to(right, { scale: 1.02, duration: 0.5, ease: 'power2.out' }, '<');
}
});
return (
<AbsoluteFill style={{ display: 'flex' }}>
<div ref={containerRef} style={{ display: 'flex', width: '100%', height: '100%' }}>
<div className="ssc-left" style={{
flex: 1, background: '#1e1e2e', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: 40, position: 'relative',
}}>
{leftLabel && <div style={{ fontSize: 24, opacity: 0.6, marginBottom: 16, color: '#fff' }}>{leftLabel}</div>}
{leftPanel}
</div>
{centerElement && (
<div className="ssc-badge" style={{
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
zIndex: 10, background: '#3b82f6', borderRadius: '50%', width: 60, height: 60,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, fontWeight: 'bold', color: '#fff',
}}>{centerElement}</div>
)}
<div className="ssc-right" style={{
flex: 1, background: 'rgba(255,255,255,0.06)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', padding: 40,
}}>
{rightLabel && <div style={{ fontSize: 24, opacity: 0.6, marginBottom: 16, color: '#fff' }}>{rightLabel}</div>}
{rightPanel}
</div>
</div>
</AbsoluteFill>
);
};**属性说明:**设置可用于右侧面板更优的对比场景。左侧面板使用深色背景(),右侧面板使用毛玻璃效果()。
dimLeft: true#1e1e2ebackdropFilter: blur(20px)7. Registered Effects
7. 注册式动画效果
Pre-register effects for fluent timeline API. Import once at entry point.
tsx
// lib/gsap-effects.ts
gsap.registerEffect({
name: 'textReveal',
effect: (targets, config) => {
const split = SplitText.create(targets, { type: 'lines', mask: 'lines' });
return gsap.from(split.lines, { y: '100%', duration: config.duration, stagger: config.stagger, ease: config.ease });
},
defaults: { duration: 0.6, stagger: 0.15, ease: 'power3.out' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'charCascade',
effect: (targets, config) => {
const split = SplitText.create(targets, { type: 'chars' });
return gsap.from(split.chars, { y: 50, opacity: 0, rotationX: -90, duration: config.duration, stagger: config.stagger, ease: config.ease });
},
defaults: { duration: 0.5, stagger: 0.02, ease: 'back.out(1.7)' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'circleReveal',
effect: (targets, config) => gsap.fromTo(targets,
{ clipPath: 'circle(0% at 50% 50%)' },
{ clipPath: 'circle(75% at 50% 50%)', duration: config.duration, ease: config.ease }),
defaults: { duration: 1, ease: 'power2.out' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'wipeIn',
effect: (targets, config) => gsap.fromTo(targets,
{ clipPath: 'inset(0 100% 0 0)' },
{ clipPath: 'inset(0 0% 0 0)', duration: config.duration, ease: config.ease }),
defaults: { duration: 0.8, ease: 'power2.inOut' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'drawIn',
effect: (targets, config) => gsap.from(targets, { drawSVG: 0, duration: config.duration, stagger: config.stagger, ease: config.ease }),
defaults: { duration: 1.5, stagger: 0.1, ease: 'power2.inOut' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'flipCard',
effect: (targets, config) => gsap.to(targets,
{ rotateY: 180, duration: config.duration, ease: config.ease }),
defaults: { duration: 1.2, ease: 'power2.inOut' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'perspectiveIn',
effect: (targets, config) => {
const fromX = config.fromRight ? 600 : -600;
const fromRotateY = config.fromRight ? -60 : 60;
return gsap.from(targets, {
x: fromX, rotateY: fromRotateY, opacity: 0,
duration: config.duration, ease: config.ease,
});
},
defaults: { duration: 0.8, ease: 'power3.out', fromRight: false },
extendTimeline: true,
});
gsap.registerEffect({
name: 'textHighlight',
effect: (targets, config) => gsap.from(targets, {
scaleX: 0, transformOrigin: 'left center',
duration: config.duration, stagger: config.stagger, ease: config.ease,
}),
defaults: { duration: 0.3, stagger: 0.3, ease: 'power2.out' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'cursorClick',
effect: (targets, config) => {
const tl = gsap.timeline();
tl.to(targets, { scale: 0.95, duration: 0.1, ease: 'power2.in' })
.to(targets, { scale: 1, duration: 0.15, ease: 'power2.out' });
return tl;
},
defaults: {},
extendTimeline: true,
});
// Simple property animations (fade, slide, scale) should use Remotion's
// interpolate() directly — GSAP registered effects are reserved for
// operations that Remotion cannot do natively (SplitText, DrawSVG, etc.).Usage:
tsx
const containerRef = useGSAPTimeline((tl, container) => {
tl.textReveal('.title')
.charCascade('.subtitle', {}, '-=0.3')
.circleReveal('.scene-2', {}, '+=0.5')
.flipCard('.card-3d')
.perspectiveIn('.panel-left')
.perspectiveIn('.panel-right', { fromRight: true }, '-=0.5')
.textHighlight('.highlight-box')
.cursorClick('.cta-button', {}, '+=0.3')
.drawIn('.logo-path');
});预注册动画效果,以实现流畅的时间线API调用。在入口文件中导入一次即可。
tsx
// lib/gsap-effects.ts
gsap.registerEffect({
name: 'textReveal',
effect: (targets, config) => {
const split = SplitText.create(targets, { type: 'lines', mask: 'lines' });
return gsap.from(split.lines, { y: '100%', duration: config.duration, stagger: config.stagger, ease: config.ease });
},
defaults: { duration: 0.6, stagger: 0.15, ease: 'power3.out' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'charCascade',
effect: (targets, config) => {
const split = SplitText.create(targets, { type: 'chars' });
return gsap.from(split.chars, { y: 50, opacity: 0, rotationX: -90, duration: config.duration, stagger: config.stagger, ease: config.ease });
},
defaults: { duration: 0.5, stagger: 0.02, ease: 'back.out(1.7)' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'circleReveal',
effect: (targets, config) => gsap.fromTo(targets,
{ clipPath: 'circle(0% at 50% 50%)' },
{ clipPath: 'circle(75% at 50% 50%)', duration: config.duration, ease: config.ease }),
defaults: { duration: 1, ease: 'power2.out' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'wipeIn',
effect: (targets, config) => gsap.fromTo(targets,
{ clipPath: 'inset(0 100% 0 0)' },
{ clipPath: 'inset(0 0% 0 0)', duration: config.duration, ease: config.ease }),
defaults: { duration: 0.8, ease: 'power2.inOut' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'drawIn',
effect: (targets, config) => gsap.from(targets, { drawSVG: 0, duration: config.duration, stagger: config.stagger, ease: config.ease }),
defaults: { duration: 1.5, stagger: 0.1, ease: 'power2.inOut' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'flipCard',
effect: (targets, config) => gsap.to(targets,
{ rotateY: 180, duration: config.duration, ease: config.ease }),
defaults: { duration: 1.2, ease: 'power2.inOut' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'perspectiveIn',
effect: (targets, config) => {
const fromX = config.fromRight ? 600 : -600;
const fromRotateY = config.fromRight ? -60 : 60;
return gsap.from(targets, {
x: fromX, rotateY: fromRotateY, opacity: 0,
duration: config.duration, ease: config.ease,
});
},
defaults: { duration: 0.8, ease: 'power3.out', fromRight: false },
extendTimeline: true,
});
gsap.registerEffect({
name: 'textHighlight',
effect: (targets, config) => gsap.from(targets, {
scaleX: 0, transformOrigin: 'left center',
duration: config.duration, stagger: config.stagger, ease: config.ease,
}),
defaults: { duration: 0.3, stagger: 0.3, ease: 'power2.out' },
extendTimeline: true,
});
gsap.registerEffect({
name: 'cursorClick',
effect: (targets, config) => {
const tl = gsap.timeline();
tl.to(targets, { scale: 0.95, duration: 0.1, ease: 'power2.in' })
.to(targets, { scale: 1, duration: 0.15, ease: 'power2.out' });
return tl;
},
defaults: {},
extendTimeline: true,
});
// 简单的属性动画(淡入、滑动、缩放)应直接使用Remotion的interpolate()
// GSAP注册式效果仅用于Remotion原生无法实现的操作(如SplitText、DrawSVG等)。使用方式:
tsx
const containerRef = useGSAPTimeline((tl, container) => {
tl.textReveal('.title')
.charCascade('.subtitle', {}, '-=0.3')
.circleReveal('.scene-2', {}, '+=0.5')
.flipCard('.card-3d')
.perspectiveIn('.panel-left')
.perspectiveIn('.panel-right', { fromRight: true }, '-=0.5')
.textHighlight('.highlight-box')
.cursorClick('.cta-button', {}, '+=0.3')
.drawIn('.logo-path');
});8. Easing Reference
8. 缓动效果参考
| Motion Feel | GSAP Ease | Use Case |
|---|---|---|
| Smooth deceleration | | Standard entrance |
| Strong deceleration | | Dramatic entrance |
| Gentle acceleration | | Standard exit |
| Smooth both | | Scene transitions |
| Slight overshoot | | Attention, bounce-in |
| Elastic spring | | Logo, playful |
| Bounce | | Impact, landing |
| Shake/vibrate | | Attention, error |
| Organic/jagged | | Tension, glitch |
| Custom curve | | Brand-specific |
| Slow in middle | | Cinematic speed ramp |
GSAP-only eases (no Remotion equivalent): CustomEase, RoughEase, SlowMo, CustomBounce, CustomWiggle, ExpoScaleEase.
| 动效质感 | GSAP缓动类型 | 适用场景 |
|---|---|---|
| 平滑减速 | | 标准入场 |
| 强烈减速 | | 戏剧性入场 |
| 平缓加速 | | 标准退场 |
| 双向平滑 | | 场景转场 |
| 轻微过冲 | | 吸引注意力、弹跳入场 |
| 弹性弹簧 | | Logo、活泼风格 |
| 弹跳效果 | | 冲击、落地效果 |
| 抖动/震动 | | 吸引注意力、错误提示 |
| 有机/不规则 | | 紧张感、故障效果 |
| 自定义曲线 | | 品牌专属动效 |
| 中间减速 | | 电影级速度渐变 |
**仅GSAP支持的缓动效果(Remotion无对应实现):**CustomEase、RoughEase、SlowMo、CustomBounce、CustomWiggle、ExpoScaleEase。
9. Combining with react-animation Skill
9. 与react-animation技能结合使用
tsx
const CombinedScene: React.FC = () => (
<AbsoluteFill>
{/* react-animation: visual atmosphere */}
<Aurora colorStops={['#3A29FF', '#FF94B4']} />
{/* gsap-animation: text + motion */}
<GSAPTextReveal text="Beautiful Motion" />
<GSAPLogoReveal svgContent={...} />
{/* react-animation: film grain overlay */}
<NoiseOverlay opacity={0.05} />
</AbsoluteFill>
);| Skill | Best For |
|---|---|
| react-animation | Visual backgrounds (Aurora, Silk, Particles), shader effects, WebGL |
| gsap-animation | Text animation, SVG motion, timeline orchestration, transitions, templates |
tsx
const CombinedScene: React.FC = () => (
<AbsoluteFill>
{/* react-animation:营造视觉氛围 */}
<Aurora colorStops={['#3A29FF', '#FF94B4']} />
{/* gsap-animation:文本+动态效果 */}
<GSAPTextReveal text="Beautiful Motion" />
<GSAPLogoReveal svgContent={...} />
{/* react-animation:胶片颗粒叠加层 */}
<NoiseOverlay opacity={0.05} />
</AbsoluteFill>
);| 技能 | 最佳适用场景 |
|---|---|
| react-animation | 视觉背景(极光、丝滑效果、粒子)、着色器效果、WebGL |
| gsap-animation | 文本动画、SVG动效、时间线编排、转场效果、模板 |
10. Composition Registration
10. 合成内容注册
tsx
export const RemotionRoot: React.FC = () => (
<>
<Composition id="TitleCard" component={TitleCard}
durationInFrames={120} fps={30} width={1920} height={1080}
defaultProps={{ mainTitle: 'HELLO WORLD', subtitle: 'A GSAP Motion Story' }} />
<Composition id="LowerThird" component={LowerThird}
durationInFrames={210} fps={30} width={1920} height={1080}
defaultProps={{ name: 'Jane Smith', title: 'Creative Director' }} />
<Composition id="LogoReveal" component={LogoReveal}
durationInFrames={90} fps={30} width={1920} height={1080}
defaultProps={{ svgContent: <circle className="logo-path" cx={50} cy={50} r={40} fill="none" stroke="#fff" strokeWidth={2} />, text: 'BRAND' }} />
<Composition id="Outro" component={Outro}
durationInFrames={120} fps={30} width={1920} height={1080}
defaultProps={{ headline: 'THANK YOU', tagline: 'See you next time' }} />
{/* Social media variants */}
<Composition id="IGStory-TitleCard" component={TitleCard}
durationInFrames={150} fps={30} width={1080} height={1920}
defaultProps={{ mainTitle: 'SWIPE UP' }} />
</>
);tsx
export const RemotionRoot: React.FC = () => (
<>
<Composition id="TitleCard" component={TitleCard}
durationInFrames={120} fps={30} width={1920} height={1080}
defaultProps={{ mainTitle: 'HELLO WORLD', subtitle: 'A GSAP Motion Story' }} />
<Composition id="LowerThird" component={LowerThird}
durationInFrames={210} fps={30} width={1920} height={1080}
defaultProps={{ name: 'Jane Smith', title: 'Creative Director' }} />
<Composition id="LogoReveal" component={LogoReveal}
durationInFrames={90} fps={30} width={1920} height={1080}
defaultProps={{ svgContent: <circle className="logo-path" cx={50} cy={50} r={40} fill="none" stroke="#fff" strokeWidth={2} />, text: 'BRAND' }} />
<Composition id="Outro" component={Outro}
durationInFrames={120} fps={30} width={1920} height={1080}
defaultProps={{ headline: 'THANK YOU', tagline: 'See you next time' }} />
{/* 社交媒体适配版本 */}
<Composition id="IGStory-TitleCard" component={TitleCard}
durationInFrames={150} fps={30} width={1080} height={1920}
defaultProps={{ mainTitle: 'SWIPE UP' }} />
</>
);11. Rendering
11. 渲染命令
bash
undefinedbash
undefinedDefault MP4
默认MP4格式
npx remotion render src/index.ts TitleCard --output out/title.mp4
npx remotion render src/index.ts TitleCard --output out/title.mp4
High quality
高质量渲染
npx remotion render src/index.ts TitleCard --codec h264 --crf 15
npx remotion render src/index.ts TitleCard --codec h264 --crf 15
GIF
GIF格式
npx remotion render src/index.ts TitleCard --codec gif --every-nth-frame 2
npx remotion render src/index.ts TitleCard --codec gif --every-nth-frame 2
ProRes for editing
用于视频编辑的ProRes格式
npx remotion render src/index.ts TitleCard --codec prores --prores-profile 4444
npx remotion render src/index.ts TitleCard --codec prores --prores-profile 4444
With audio
添加音频
Use <Audio src={staticFile('bgm.mp3')} /> in composition
在合成内容中使用<Audio src={staticFile('bgm.mp3')} />
Transparent background (alpha channel)
透明背景(带alpha通道)
npx remotion render src/index.ts Overlay --codec prores --prores-profile 4444 --output out/overlay.mov
npx remotion render src/index.ts Overlay --codec vp9 --output out/overlay.webm
undefinednpx remotion render src/index.ts Overlay --codec prores --prores-profile 4444 --output out/overlay.mov
npx remotion render src/index.ts Overlay --codec vp9 --output out/overlay.webm
undefinedTransparent Background Formats
透明背景支持格式
| Format | Alpha Support | Quality | File Size | Compatibility |
|---|---|---|---|---|
ProRes 4444 ( | Yes | Lossless | Large | Final Cut, Premiere, DaVinci |
WebM VP9 ( | Yes | Lossy | Small | Web, Chrome, After Effects |
MP4 H.264 ( | No | Lossy | Small | Universal playback |
GIF ( | 1-bit only | Low | Medium | Web, social |
Use ProRes 4444 for professional compositing. Use WebM VP9 for web overlays. MP4/H.264 does not support alpha channels.
| 格式 | 支持Alpha通道 | 质量 | 文件大小 | 兼容性 |
|---|---|---|---|---|
ProRes 4444( | 是 | 无损 | 大 | Final Cut、Premiere、DaVinci |
WebM VP9( | 是 | 有损 | 小 | 网页、Chrome、After Effects |
MP4 H.264( | 否 | 有损 | 小 | 通用播放 |
GIF( | 仅1位 | 低 | 中等 | 网页、社交媒体 |
专业合成请使用ProRes 4444。网页叠加层请使用WebM VP9。MP4/H.264不支持alpha通道。