Core Principle
GSAP timelines are created paused and seeked to frame / fps on every frame. Remotion controls time; GSAP provides animation logic. This produces deterministic, frame-perfect video output.
Remotion Frame -> Time Conversion -> GSAP Timeline Seek
frame = 0 -> tl.seek(0) -> start state
frame = 15 -> tl.seek(0.5) -> 0.5s state (@ 30fps)
frame = 30 -> tl.seek(1.0) -> 1.0s state
useGSAPTimeline Hook
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);
// Build timeline once (paused)
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; };
}, []);
// Seek to current frame
useEffect(() => {
if (tlRef.current) tlRef.current.seek(frame / fps);
}, [frame, fps]);
return containerRef;
}
useGSAPWithFonts Hook (for SplitText)
SplitText measures text dimensions, so fonts must be loaded first. Use delayRender() to block rendering until ready.
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;
}
Determinism Rules
- Always paused + seek -- never
tl.play(), nevertl.resume() - No real-time deps -- no
Date.now(),setTimeout,requestAnimationFrame,gsap.ticker - Seeded randomness -- no
Math.random()orgsap.utils.random(); use seeded PRNG - No stateful callbacks -- avoid
onUpdatethat accumulates state - No interactive plugins -- no Draggable, Observer, Inertia, ScrollTrigger
- No live DOM measurement -- pre-calculate in build phase, not per-frame
- Pure function of frame -- same frame number always produces same visual
// Seeded random for deterministic "random" values
function seededRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
// Deterministic stagger (instead of from: "random")
tl.from(items, {
y: 100, opacity: 0,
stagger: (index) => seededRandom(index * 7919) * 0.5,
});
Timeline Duration to Remotion Frames
// Calculate Composition durationInFrames from GSAP timeline
const tl = gsap.timeline({ paused: true });
// ... build timeline ...
const totalSeconds = tl.totalDuration();
const durationInFrames = Math.ceil(totalSeconds * fps);
Performance Tips
- Build once, seek per frame -- timeline in
useEffect([], []), seek inuseEffect([frame]) - Use
gsap.context()-- scopes animations to container, enables clean revert - Use
React.memofor static containers around animated elements - Use
<Freeze>for elements after their animation completes - Keep timelines under ~100 tweens --
seek()resolves all tweens at position - Prefer transforms over filters --
filter: blur()is slow without GPU - SplitText: split once in build phase, not per-frame
Combining GSAP + Remotion interpolate()
Use GSAP for complex sequences, Remotion interpolate() for simple properties:
const MyScene: React.FC = () => {
const frame = useCurrentFrame();
// Simple fade via Remotion native (no GSAP needed)
const bgOpacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' });
// Complex text sequence via GSAP
const containerRef = useGSAPTimeline((tl, container) => {
tl.from(container.querySelectorAll('.char'), {
opacity: 0, y: 50, rotationX: -90,
stagger: 0.05, duration: 0.8, ease: 'back.out(1.7)',
});
});
return (
<AbsoluteFill style={{ opacity: bgOpacity }}>
<div ref={containerRef}>...</div>
</AbsoluteFill>
);
};
Flickering Prevention (Parallel Rendering)
Remotion renders frames in multiple browser tabs in parallel. Each tab:
- Creates its own component instance
- Builds its own GSAP timeline
- Seeks independently
This works correctly because:
- Timeline construction is deterministic (same props = same timeline)
seek()is stateless (same time = same output)- No cross-frame state dependency
Avoid:
useStatefor animation values (derive fromuseCurrentFrame())- Global mutable state
- GSAP's ticker or global timeline
Audio Sync
Use Remotion's <Audio> + <Sequence> for frame-synced audio:
<AbsoluteFill>
<Audio src={staticFile('bgm.mp3')} volume={0.5} />
<Sequence from={30} durationInFrames={60}>
<Audio src={staticFile('whoosh.mp3')} volume={0.8} />
</Sequence>
<GSAPAnimatedScene />
</AbsoluteFill>