Remotion: Programmatic Video with React¶
Remotion renders React components to video frames. Every animation is driven by useCurrentFrame() — no CSS transitions, no runtime state. Output is deterministic and scriptable.
Core Concepts¶
Frame = time. At 30fps, frame 90 = 3 seconds. All animation is a function of the current frame number.
import { useCurrentFrame, useVideoConfig } from 'remotion';
const MyScene: React.FC = () => {
const frame = useCurrentFrame(); // current frame (0-based)
const { fps, width, height } = useVideoConfig();
return <div style={{ opacity: frame / 30 }}>Fades in over 1 second</div>;
};
CRITICAL: Never use CSS transition, animation, or @keyframes. They don't execute during headless rendering — only interpolate() and spring() work.
Project Structure¶
src/
Root.tsx # registerRoot() entry point
Composition.tsx # all compositions registered here
videos/ProductDemo/
index.tsx # main composition
scenes/
Hook.tsx
Problem.tsx
Solution.tsx
Features.tsx
CTA.tsx
components/
AnimatedText.tsx
ProductImage.tsx
constants.ts # colors, fonts, timing - edit here only
types.ts
lib/
animations.ts # reusable spring/interpolate helpers
fonts.ts # font loading
public/
music/
images/
sounds/
Constants-first pattern - all tunable values in one file:
// constants.ts
export const COLORS = {
bg: '#000000',
text: '#FFFFFF',
accent: '#007AFF',
secondary: '#8E8E93',
} as const;
export const TIMING = {
fps: 30,
sceneDuration: 150, // 5s at 30fps
fadeIn: 15, // 0.5s
stagger: 4, // ~133ms between items
} as const;
export const FONTS = {
heading: 'Inter',
mono: 'JetBrains Mono',
} as const;
Animation Primitives¶
interpolate() - frame to value mapping¶
import { interpolate, Easing, useCurrentFrame } from 'remotion';
const frame = useCurrentFrame();
// Fade in over 15 frames (0.5s @30fps)
const opacity = interpolate(frame, [0, 15], [0, 1], {
easing: Easing.out(Easing.cubic),
extrapolateRight: 'clamp',
});
// Slide up 40px over 20 frames
const translateY = interpolate(frame, [0, 20], [40, 0], {
easing: Easing.out(Easing.cubic),
extrapolateRight: 'clamp',
});
// Apple-style bezier easing
const smooth = interpolate(frame, [0, 30], [0, 100], {
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
extrapolateRight: 'clamp',
});
Always use extrapolateRight: 'clamp' to freeze at final value. Without it, values continue beyond keyframe range.
spring() - physics-based animation¶
import { spring, useCurrentFrame, useVideoConfig } from 'remotion';
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Spring presets
const SPRING_CONFIGS = {
smooth: { damping: 200 }, // default - smooth settle
snappy: { damping: 20, stiffness: 200 }, // quick, minimal bounce
bouncy: { damping: 8, stiffness: 200 }, // noticeable bounce
gentle: { damping: 200, stiffness: 50 }, // slow settle for large elements
} as const;
const scale = spring({
frame,
fps,
from: 0,
to: 1,
config: SPRING_CONFIGS.snappy,
});
Easing reference¶
| Easing | When | Remotion code |
|---|---|---|
| ease-out | Element entering | Easing.out(Easing.cubic) |
| ease-in | Element leaving | Easing.in(Easing.cubic) |
| ease-in-out | Element moving on-screen | Easing.inOut(Easing.cubic) |
| spring smooth | Most elements | spring({config: {damping: 200}}) |
| spring bouncy | Playful / badges | spring({config: {damping: 8}}) |
| linear | Progress bars only | Easing.linear |
Common bezier presets:
const EASING = {
appleEase: Easing.bezier(0.25, 0.1, 0.25, 1.0),
materialStandard: Easing.bezier(0.4, 0.0, 0.2, 1.0),
materialDecel: Easing.bezier(0.0, 0.0, 0.2, 1.0),
materialAccel: Easing.bezier(0.4, 0.0, 1.0, 1.0),
snappyIn: Easing.bezier(0.0, 0.0, 0.15, 1.0),
};
Scene Composition¶
Sequence - non-overlapping scenes¶
import { AbsoluteFill, Sequence } from 'remotion';
export const ProductDemo: React.FC = () => (
<AbsoluteFill style={{ backgroundColor: '#000' }}>
<Sequence from={0} durationInFrames={150}><HookScene /></Sequence>
<Sequence from={150} durationInFrames={300}><ProblemScene /></Sequence>
<Sequence from={450} durationInFrames={300}><SolutionScene /></Sequence>
<Sequence from={750} durationInFrames={450}><FeaturesScene /></Sequence>
<Sequence from={1200} durationInFrames={150}><CTAScene /></Sequence>
</AbsoluteFill>
);
Inside each Scene component, useCurrentFrame() resets to 0 at the sequence start.
TransitionSeries - scenes with transitions¶
import { TransitionSeries, springTiming, linearTiming } from '@remotion/transitions';
import { fade } from '@remotion/transitions/fade';
import { slide } from '@remotion/transitions/slide';
export const ProductDemo: React.FC = () => (
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={150}>
<HookScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={springTiming({ config: { damping: 200 } })}
/>
<TransitionSeries.Sequence durationInFrames={300}>
<ProblemScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: 'from-right' })}
timing={linearTiming({ durationInFrames: 12 })}
/>
<TransitionSeries.Sequence durationInFrames={300}>
<SolutionScene />
</TransitionSeries.Sequence>
</TransitionSeries>
);
Note: TransitionSeries overlaps scenes during transition - total duration is shorter than sum of parts.
Assets¶
import { Img, staticFile, OffthreadVideo, Audio } from 'remotion';
// Image - blocks render until loaded (no broken frames)
<Img src={staticFile('product-hero.png')} style={{ width: '100%' }} />
// Video - renders offthread for better performance vs <Video>
<OffthreadVideo src={staticFile('demo.mp4')} />
// Background music
<Audio src={staticFile('music/ambient.mp3')} volume={0.3} />
// Volume fade out over last 30 frames
<Audio
src={staticFile('music/bg.mp3')}
volume={(f) =>
interpolate(f, [totalFrames - 30, totalFrames], [0.3, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
})
}
/>
// Sound effect at frame 90
<Sequence from={90}>
<Audio src={staticFile('sounds/whoosh.mp3')} volume={0.5} />
</Sequence>
Always use staticFile() for public/ assets - works in Studio, Lambda, and Cloud Run.
Font Loading¶
// fonts.ts
import { loadFont as loadInter } from '@remotion/google-fonts/Inter';
import { loadFont as loadJetBrainsMono } from '@remotion/google-fonts/JetBrainsMono';
const { fontFamily: interFamily } = loadInter('normal', {
weights: ['400', '500', '600', '700', '800'],
subsets: ['latin'],
});
export const FONT_FAMILIES = {
heading: interFamily,
mono: loadJetBrainsMono('normal', { weights: ['400', '700'] }).fontFamily,
};
loadFont() blocks render until font is ready - no FOUT in exported video.
Stagger Patterns¶
Linear stagger:
const STAGGER_DELAY = 8; // frames between items
{items.map((item, i) => {
const delay = i * STAGGER_DELAY;
return (
<div key={i} style={{
opacity: interpolate(frame, [delay, delay + 15], [0, 1],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
transform: `translateY(${interpolate(frame, [delay, delay + 20], [30, 0], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
})}px)`,
}}>
{item}
</div>
);
})}
Grid stagger (diagonal):
const COLS = 3;
const STAGGER = 4; // frames
{items.map((item, index) => {
const row = Math.floor(index / COLS);
const col = index % COLS;
const delay = (row + col) * STAGGER;
return (
<div key={index} style={{
opacity: interpolate(frame, [delay, delay + 15], [0, 1],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
}}>
{item}
</div>
);
})}
Reusable Animation Helpers¶
// lib/animations.ts
import { interpolate, Easing, spring } from 'remotion';
export const SPRING_CONFIGS = {
smooth: { damping: 200 },
snappy: { damping: 20, stiffness: 200 },
bouncy: { damping: 8, stiffness: 200 },
gentle: { damping: 200, stiffness: 50 },
} as const;
export const fadeIn = (frame: number, startFrame = 0, duration = 15) =>
interpolate(frame, [startFrame, startFrame + duration], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
export const fadeOut = (frame: number, startFrame: number, duration = 12) =>
interpolate(frame, [startFrame, startFrame + duration], [1, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
export const slideInFromBottom = (frame: number, startFrame = 0, distance = 40, duration = 20) =>
interpolate(frame, [startFrame, startFrame + duration], [distance, 0], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
export const scalePop = (frame: number, fps: number, delay = 0, config = SPRING_CONFIGS.snappy) =>
spring({ frame: frame - delay, fps, from: 0, to: 1, config });
export const staggerDelay = (index: number, delayPerItem = 4) => index * delayPerItem;
export const countTo = (frame: number, target: number, startFrame = 0, duration = 30) =>
Math.round(interpolate(frame, [startFrame, startFrame + duration], [0, target], {
easing: Easing.out(Easing.cubic),
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
}));
export const typewriter = (text: string, frame: number, startFrame = 0, charsPerFrame = 0.5) => {
const chars = Math.floor((frame - startFrame) * charsPerFrame);
return text.slice(0, Math.max(0, Math.min(chars, text.length)));
};
Logo Animation Patterns¶
// Pattern 1: Fade + Scale (most professional)
const logoOpacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' });
const logoScale = interpolate(frame, [0, 25], [0.9, 1], {
easing: Easing.out(Easing.cubic), extrapolateRight: 'clamp',
});
// Pattern 2: Stroke draw reveal (for line logos)
const pathLength = 200; // measure from SVG
const drawProgress = interpolate(frame, [0, 40], [pathLength, 0], { extrapolateRight: 'clamp' });
// Apply: strokeDasharray={pathLength} strokeDashoffset={drawProgress}
// Pattern 3: Mask/wipe reveal
const clipX = interpolate(frame, [0, 30], [0, 100], {
easing: Easing.out(Easing.cubic), extrapolateRight: 'clamp',
});
// Apply: clipPath: `inset(0 ${100 - clipX}% 0 0)`
Parallax Depth Effect¶
const scrollProgress = interpolate(frame, [0, 90], [0, 100], { extrapolateRight: 'clamp' });
<div style={{ transform: `translateY(${-scrollProgress * 0.3}px)` }}>
<BackgroundLayer /> {/* background: 0.2-0.4x speed */}
</div>
<div style={{ transform: `translateY(${-scrollProgress * 0.7}px)` }}>
<ProductImage /> {/* midground: 0.6-0.8x */}
</div>
<div style={{ transform: `translateY(${-scrollProgress * 1.0}px)` }}>
<FloatingElements /> {/* foreground: 1.0-1.3x */}
</div>
Render Performance¶
- Use
npx remotion benchmarkto find optimal--concurrencyvalue <OffthreadVideo>over<Video>- renders outside main threaduseMemofor expensive calculations (not for animation values - those change every frame)staticFile()for all assets - handles paths correctly across environments- Keep composition tree shallow - deep nesting multiplies render cost
- Render controls as sibling to
<Player>, not child - avoids unnecessary re-renders
Ecosystem - Official Integrations¶
| Package | What it adds |
|---|---|
@remotion/three | React Three Fiber + <ThreeCanvas> |
@remotion/lottie | After Effects animations via JSON |
@remotion/transitions | TransitionSeries + built-in transitions |
@remotion/google-fonts | Type-safe font loading |
@remotion/lambda | AWS Lambda render farm |
@remotion/player | In-browser preview component |
Community Toolkits (Claude Code Skills)¶
remotion-dev/skills- official first-party Claude Code skills (npx skills add remotion-dev/skills)digitalsamba/claude-code-video-toolkit- product demos with ElevenLabs voiceover, dark tech aestheticBayramAnnakov/remotion-video-director- interactive deliberation: thinking → designing → building → reviewingremotion-dev/apple-wow-tutorial- reference implementation for Apple animation style
Pre-Render Checklist¶
- [ ] All text on screen >= 32px (body), >= 64px (headings) at 1080p
- [ ] Every text block readable for minimum 2s after animation completes
- [ ] Contrast ratio >= 7:1 for text/background pairs
- [ ] Max 3 brand colors + black/white/gray
- [ ] No CSS transitions/animations - only
interpolate()/spring() - [ ] Consistent animation direction throughout
- [ ] Audio levels: music -12 to -18 dB relative to speech
- [ ] Logo has clear space (min 1x logo height on all sides)
- [ ] CTA is last thing on screen (3-5s dwell after animation)
- [ ] Duration matches target platform (15s social, 60s product, 90s pitch)
Gotchas¶
- CSS animations silently break on export. The headless renderer doesn't run CSS keyframes.
transition: opacity 0.3slooks fine in Studio preview but produces static output. Useinterpolate()for all animation. useCurrentFrame()is absolute inside Sequence. Inside a<Sequence from={150}>, frame 0 = absolute frame 150. This is intentional - scenes are self-contained.- TransitionSeries shortens total duration. Each transition overlaps adjacent scenes. A 30-frame transition between two 150-frame scenes gives total 270 frames, not 300. Account for this in
<Composition durationInFrames>. spring()never exactly reaches target. It asymptotically approaches. For exact final values useinterpolate()withextrapolateRight: 'clamp'. Springs are for feel, not precision.- Font FOUT doesn't appear in video.
loadFont()blocks rendering until ready. No need for fallback fonts. - Remote images require
allowFullScreen. Local assets viastaticFile()are safer for reproducible renders.