When to use
Use this skill when creating Remotion video compositions that need GSAP's advanced animation capabilities beyond Remotion's built-in interpolate() and 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 interpolate() when:
-
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.).
Setup
In a Remotion project
npm install gsap
// 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 };
Core Hook: useGSAPTimeline
The bridge between GSAP and Remotion. Creates a paused timeline, seeks it to frame / fps every frame.
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):
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; }
- Text Animations
SplitText Reveal (chars/words/lines)
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 type: "lines", mask: "lines"
from lines: { y: "100%" }
Char cascade type: "chars"
from chars: { y: 50, opacity: 0, rotationX: -90 }
Word scale type: "words"
from words: { scale: 0, opacity: 0 }
Char + color type: "chars"
.from(chars, { y: 50 }).to(chars, { color: "#f00" })
ScrambleText (decode effect)
Determinism warning: ScrambleText uses internal random character selection. Use --concurrency=1 when rendering to guarantee frame-perfect reproducibility across renders.
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: "upperCase" , "lowerCase" , "upperAndLowerCase" , "01" , or custom string.
Text Highlight Box
Colored rectangles scale in behind specific words. Uses SplitText for word-level positioning, then absolutely-positioned <div> boxes at lower z-index.
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: highlights is an array of { wordIndex, color } targeting specific words (0-indexed from SplitText).
- SVG Animations
MorphSVG (shape morphing)
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
type
"linear" (default), "rotational"
map
"size" , "position" , "complexity"
shapeIndex
Integer for point alignment offset
DrawSVG (stroke animation)
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 0
Draw from center "50% 50%"
Show segment "20% 80%"
Erase "100% 100%"
MotionPath (path following)
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', }); });
- 3D Transform Patterns
Performance note: Limit to 3-4 simultaneous 3D containers per scene. Each preserve-3d container triggers GPU compositing layers.
CardFlip3D
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> ); };
PerspectiveEntrance
Two elements enter from opposite sides with rotateY, converging to center.
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
Outgoing text tilts backward, incoming text falls forward. Uses transformOrigin to pivot from the correct edge.
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> ); };
- Interaction Simulation
CursorClick
Simulates a cursor navigating to a target and clicking. Cursor slides in, target depresses, ripple expands.
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: targetSelector is a CSS selector for the element to "click" within the container. Cursor enters from off-screen right.
- Transitions
Clip-Path Transitions
Performance note: Complex clip-path animations (especially polygon ) can slow down frame generation in Remotion's headless Chrome. If render times are high, consider replacing with opacity/transform-based alternatives or simplify to circle /inset shapes.
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 circle(0% at 50% 50%)
circle(75% at 50% 50%)
Wipe left polygon(0 0, 0 0, 0 100%, 0 100%)
polygon(0 0, 100% 0, 100% 100%, 0 100%)
Iris polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)
polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)
Blinds inset(0 0 100% 0)
inset(0 0 0% 0)
Slide / Crossfade
// 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);
- Templates
Lower Third
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> ); };
Title Card
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)
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 interpolate() for deterministic frame-by-frame calculation. No GSAP needed — counters are pure math.
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> ); };
Outro (closing scene)
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.
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 dimLeft: true for comparison scenes where right panel should "win". Left panel uses dark background (#1e1e2e ), right panel uses glassmorphism (backdropFilter: blur(20px) ).
- Registered Effects
Pre-register effects for fluent timeline API. Import once at entry point.
// 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:
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'); });
- Easing Reference
Motion Feel GSAP Ease Use Case
Smooth deceleration power2.out
Standard entrance
Strong deceleration power3.out / expo.out
Dramatic entrance
Gentle acceleration power2.in
Standard exit
Smooth both power1.inOut
Scene transitions
Slight overshoot back.out(1.7)
Attention, bounce-in
Elastic spring elastic.out(1, 0.5)
Logo, playful
Bounce CustomBounce
Impact, landing
Shake/vibrate CustomWiggle
Attention, error
Organic/jagged RoughEase
Tension, glitch
Custom curve CustomEase.create("id", "M0,0 C...")
Brand-specific
Slow in middle slow(0.7, 0.7, false)
Cinematic speed ramp
GSAP-only eases (no Remotion equivalent): CustomEase, RoughEase, SlowMo, CustomBounce, CustomWiggle, ExpoScaleEase.
- Combining with react-animation Skill
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
- Composition Registration
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' }} /> </> );
- Rendering
Default 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
GIF
npx remotion render src/index.ts TitleCard --codec gif --every-nth-frame 2
ProRes for editing
npx remotion render src/index.ts TitleCard --codec prores --prores-profile 4444
With audio
Use <Audio src={staticFile('bgm.mp3')} /> in composition
Transparent background (alpha channel)
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
Transparent Background Formats
Format Alpha Support Quality File Size Compatibility
ProRes 4444 (.mov ) Yes Lossless Large Final Cut, Premiere, DaVinci
WebM VP9 (.webm ) Yes Lossy Small Web, Chrome, After Effects
MP4 H.264 (.mp4 ) No Lossy Small Universal playback
GIF (.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.