
Remotion Beautiful Videos
- 4 installs
- Updated May 7, 2026
- norahe0304-art/remotion-video-skill
Helps with ai & agent building tasks.
About
remotion-beautiful-videos is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remotion-beautiful-videos
- AI & Agent Building
- AI-coding skill
Remotion Beautiful Videos by the numbers
- 4 all-time installs (skills.sh)
- Ranked #13,348 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/norahe0304-art/remotion-video-skill --skill remotion-beautiful-videosAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| Last updated | May 7, 2026 |
| Repository | norahe0304-art/remotion-video-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
When to use
Use this skills whenever you are dealing with Remotion code to obtain the domain-specific knowledge.
Captions
When dealing with captions or subtitles, load the ./rules/subtitles.md file for more information.
Using FFmpeg
For some video operations, such as trimming videos or detecting silence, FFmpeg should be used. Load the ./rules/ffmpeg.md file for more information.
Audio visualization
When needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the ./rules/audio-visualization.md file for more information.
Sound effects
When needing to use sound effects, load the ./rules/sound-effects.md file for more information.
How to use
Read individual rule files for detailed explanations and code examples:
- rules/3d.md - 3D content in Remotion using Three.js and React Three Fiber
- rules/animations.md - Fundamental animation skills for Remotion
- rules/assets.md - Importing images, videos, audio, and fonts into Remotion
- rules/audio.md - Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
- rules/calculate-metadata.md - Dynamically set composition duration, dimensions, and props
- rules/can-decode.md - Check if a video can be decoded by the browser using Mediabunny
- rules/charts.md - Chart and data visualization patterns for Remotion (bar, pie, line, stock charts)
- rules/compositions.md - Defining compositions, stills, folders, default props and dynamic metadata
- rules/extract-frames.md - Extract frames from videos at specific timestamps using Mediabunny
- rules/fonts.md - Loading Google Fonts and local fonts in Remotion
- rules/get-audio-duration.md - Getting the duration of an audio file in seconds with Mediabunny
- rules/get-video-dimensions.md - Getting the width and height of a video file with Mediabunny
- rules/get-video-duration.md - Getting the duration of a video file in seconds with Mediabunny
- rules/gifs.md - Displaying GIFs synchronized with Remotion's timeline
- rules/images.md - Embedding images in Remotion using the Img component
- rules/light-leaks.md - Light leak overlay effects using @remotion/light-leaks
- rules/lottie.md - Embedding Lottie animations in Remotion
- rules/measuring-dom-nodes.md - Measuring DOM element dimensions in Remotion
- rules/measuring-text.md - Measuring text dimensions, fitting text to containers, and checking overflow
- rules/sequencing.md - Sequencing patterns for Remotion - delay, trim, limit duration of items
- rules/tailwind.md - Using TailwindCSS in Remotion
- rules/text-animations.md - Typography and text animation patterns for Remotion
- rules/timing.md - Interpolation curves in Remotion - linear, easing, spring animations
- rules/transitions.md - Scene transition patterns for Remotion
- rules/transparent-videos.md - Rendering out a video with transparency
- rules/trimming.md - Trimming patterns for Remotion - cut the beginning or end of animations
- rules/videos.md - Embedding videos in Remotion - trimming, volume, speed, looping, pitch
- rules/parameters.md - Make a video parametrizable by adding a Zod schema
- rules/maps.md - Add a map using Mapbox and animate it
- rules/voiceover.md - Adding AI-generated voiceover to Remotion compositions using ElevenLabs TTS
node_modules/
dist/
out/
.DS_Store
*.mp4
*.mov
scaffold/node_modules/
Remotion Video Skill
Generate premium 40-second product launch videos with Claude Code + Remotion. Give it a brand URL, get a video that looks like a $50k agency produced it.
Installation
npx skills add norahe0304-art/remotion-video-skill -gOr project-level:
npx skills add norahe0304-art/remotion-video-skillUsage
Just tell Claude Code:
"Make a launch video for linear.app"
The skill will scrape the brand, ask for your storyline, build animated UI mockups, add BGM, and render to MP4.
What's Inside
3,200+ lines of rules across 13 files, 4,100+ lines of bundled Remotion API reference, 1,900+ lines of scaffold code, and 5 reference files with copy-paste components.
Design System (rules/)
| File | What it covers |
|---|---|
layout.md | Grid system, safe zones, nowrap rules, card padding minimums, SplitText flex-column fix |
typography.md | Font weights (max 600), size hierarchy (min 28px), text safety |
color.md | Brand polarity detection, neutral tinting, 60-30-10 rule |
ui-mockups.md | Product UI construction, density, title bars, syntax coloring |
cards.md | Staggered card grids, interior fill, animated card bodies |
data-viz.md | SVG charts, animated metrics, tabular-nums, status badges |
Motion & Production (rules/)
| File | What it covers |
|---|---|
motion.md | Spring physics, easeOutExpo, per-character stagger, timing standards |
transitions.md | TransitionSeries, light leaks, fluid backgrounds, parallax |
cinematic.md | Film grain, vignette, shimmer sweep, color grading, render settings |
Strategy & Quality (rules/)
| File | What it covers |
|---|---|
taste.md | 23-item AI slop blacklist, cognitive UX laws, self-review checklist |
narrative.md | 6-act structure, headline/UI rhythm, story arc |
narrative-templates.md | 7 industry templates (AI SaaS, FinTech, DevTool, E-Commerce, etc.) |
workflow.md | Brand scraping, yt-dlp video download, staticFile() enforcement, BGM sourcing, asset relevance checks |
Code Patterns (references/)
| File | Components |
|---|---|
animations.md | FadeIn, ScaleIn, SplitText, Typewriter, CountUp, AnimatedPath |
components.md | GradientMesh, GlassPanel, FilmGrain, ProductFrame, BrandIcon |
audio.md | Beat sync, voiceover ducking, audio layers |
visual-effects.md | ShimmerSweep, PulseGlow, PathDraw, ParticleField |
lottie.md | @remotion/lottie integration |
Bundled Remotion API Reference (remotion-best-practices/)
37 Remotion-specific rule files covering videos, audio, timing, transitions, compositions, fonts, images, charts, captions, 3D, maps, and more. No external dependency needed.
Scaffold (scaffold/)
11-file ready-to-run Remotion project template with pre-wired TransitionSeries, animation components, background effects, and theme tokens.
Battle-Tested
Built and validated across 8 real brand videos: Google Gemini, Linear, Mercury, Shopify, 1Password, Notion, Cursor, and Anthropic. Every rule comes from a real bug or a real design review — not theory.
License
MIT
Animation Component Library
Reusable animation components for Remotion videos. Copy into any project's components/Animations.tsx.
FadeIn — Directional Fade with Easing
import { useCurrentFrame, interpolate, Easing } from "remotion";
interface FadeInProps {
children: React.ReactNode;
delay?: number;
duration?: number;
direction?: "up" | "down" | "left" | "right" | "none";
distance?: number;
}
export const FadeIn: React.FC<FadeInProps> = ({
children, delay = 0, duration = 30,
direction = "up", distance = 30,
}) => {
const frame = useCurrentFrame();
const progress = interpolate(frame - delay, [0, duration], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const translate = {
up: `translateY(${(1 - progress) * distance}px)`,
down: `translateY(${(1 - progress) * -distance}px)`,
left: `translateX(${(1 - progress) * distance}px)`,
right: `translateX(${(1 - progress) * -distance}px)`,
none: "none",
};
return (
<div style={{
opacity: progress,
transform: translate[direction],
willChange: "opacity, transform",
}}>
{children}
</div>
);
};ScaleIn — Spring-Based Scale Entrance
import { useCurrentFrame, spring, useVideoConfig } from "remotion";
interface ScaleInProps {
children: React.ReactNode;
delay?: number;
from?: number;
to?: number;
config?: { damping?: number; stiffness?: number; mass?: number };
}
export const ScaleIn: React.FC<ScaleInProps> = ({
children, delay = 0, from = 0, to = 1,
config = { damping: 16, stiffness: 80, mass: 0.8 },
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - delay, fps, config });
const scale = from + (to - from) * progress;
return (
<div style={{
transform: `scale(${scale})`,
opacity: progress,
willChange: "transform, opacity",
}}>
{children}
</div>
);
};Typewriter — True Typing (No Reflow)
CRITICAL: Never use text.slice() — it causes container reflow (width changes per frame), making text appear to slide in from the right instead of typing. Pre-render ALL characters, hide untyped ones with color: transparent.
import { useCurrentFrame } from "remotion";
export const Typewriter: React.FC<{
text: string;
delay?: number;
speed?: number; // characters per frame
cursor?: boolean;
cursorChar?: string;
style?: React.CSSProperties;
visibleColor?: string;
}> = ({
text, delay = 0, speed = 2, cursor = true,
cursorChar = "|", style, visibleColor,
}) => {
const frame = useCurrentFrame();
const elapsed = Math.max(0, frame - delay);
const charCount = Math.min(Math.floor(elapsed * speed), text.length);
const showCursor = cursor && charCount < text.length && frame % 16 < 10;
const color = visibleColor || style?.color || "inherit";
return (
<span style={style}>
{text.split("").map((char, i) => (
<span key={i} style={{
color: i < charCount ? color : "transparent",
}}>
{char}
</span>
))}
{showCursor && (
<span style={{ color, opacity: 0.6, marginLeft: -2 }}>{cursorChar}</span>
)}
</span>
);
};CountUp — Animated Number with Easing
import { useCurrentFrame, interpolate, Easing } from "remotion";
interface CountUpProps {
from?: number;
to: number;
delay?: number;
duration?: number;
prefix?: string;
suffix?: string;
decimals?: number;
style?: React.CSSProperties;
}
export const CountUp: React.FC<CountUpProps> = ({
from = 0, to, delay = 0, duration = 60,
prefix = "", suffix = "", decimals = 0, style,
}) => {
const frame = useCurrentFrame();
const progress = interpolate(frame - delay, [0, duration], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const value = from + (to - from) * progress;
return (
<span style={style}>
{prefix}{value.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}{suffix}
</span>
);
};AnimatedText — Multi-Mode Text Animation
import { useCurrentFrame, spring, interpolate, useVideoConfig, Easing } from "remotion";
type AnimMode = "fadeUp" | "typewriter" | "scale" | "slideIn";
interface AnimatedTextProps {
text: string;
mode?: AnimMode;
delay?: number;
duration?: number;
style?: React.CSSProperties;
}
export const AnimatedText: React.FC<AnimatedTextProps> = ({
text, mode = "fadeUp", delay = 0, duration = 30, style,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = frame - delay;
const animations: Record<AnimMode, React.CSSProperties> = {
fadeUp: {
opacity: interpolate(t, [0, duration], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
}),
transform: `translateY(${interpolate(t, [0, duration], [40, 0], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
})}px)`,
},
typewriter: {
clipPath: `inset(0 ${interpolate(t, [0, duration], [100, 0], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
})}% 0 0)`,
},
scale: (() => {
const s = spring({ frame: t, fps, config: { damping: 14, stiffness: 100 } });
return { transform: `scale(${s})`, opacity: s };
})(),
slideIn: {
transform: `translateX(${interpolate(t, [0, duration], [-100, 0], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
})}px)`,
opacity: interpolate(t, [0, duration * 0.4], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
}),
},
};
return <div style={{ ...style, ...animations[mode] }}>{text}</div>;
};AnimatedPath — SVG Self-Drawing with Glow
Requires @remotion/paths:
import { useCurrentFrame, interpolate, Easing } from "remotion";
import { evolvePath } from "@remotion/paths";
interface AnimatedPathProps {
d: string;
stroke?: string;
strokeWidth?: number;
delay?: number;
duration?: number;
glow?: boolean;
glowColor?: string;
}
export const AnimatedPath: React.FC<AnimatedPathProps> = ({
d, stroke = "#20A1A7", strokeWidth = 2,
delay = 0, duration = 60, glow = true, glowColor,
}) => {
const frame = useCurrentFrame();
const progress = interpolate(frame - delay, [0, duration], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
const evolved = evolvePath(progress, d);
return (
<svg style={{ position: "absolute", inset: 0, overflow: "visible" }}>
{glow && (
<defs>
<filter id="pathGlow">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
)}
<path
d={d}
stroke={stroke}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={evolved.strokeDasharray}
strokeDashoffset={evolved.strokeDashoffset}
filter={glow ? "url(#pathGlow)" : undefined}
style={{ filter: glow ? `drop-shadow(0 0 6px ${glowColor || stroke})` : undefined }}
/>
</svg>
);
};Stagger Pattern — Coordinating Multiple Animations
const STAGGER = 8; // frames between each element
const items = ["Feature A", "Feature B", "Feature C"];
<>
{items.map((item, i) => (
<FadeIn key={i} delay={BASE_DELAY + i * STAGGER} direction="up" distance={20}>
<FeatureCard title={item} />
</FadeIn>
))}
</>SplitText — Per-Character Stagger (Premium Headlines)
Headlines must NEVER fade in as a block. Split into characters with spring stagger:
import React from "react";
import { useCurrentFrame, spring } from "remotion";
interface SplitTextProps {
text: string;
delay?: number;
staggerFrames?: number;
distance?: number;
style?: React.CSSProperties;
}
export const SplitText: React.FC<SplitTextProps> = ({
text, delay = 0, staggerFrames = 2, distance = 20, style,
}) => {
const frame = useCurrentFrame();
return (
<span style={{ display: "flex", flexWrap: "wrap", ...style }}>
{text.split("").map((char, i) => {
const charDelay = delay + i * staggerFrames;
const progress = spring({
frame: frame - charDelay, fps: 30,
config: { damping: 20, stiffness: 100 },
});
return (
<span key={i} style={{
opacity: progress,
transform: `translateY(${(1 - progress) * distance}px)`,
display: "inline-block",
minWidth: char === " " ? "0.3em" : undefined,
}}>
{char}
</span>
);
})}
</span>
);
};SplitWords — Per-Word Stagger (Subtitles)
For subtitles where per-character is too slow:
export const SplitWords: React.FC<{
text: string;
delay?: number;
staggerFrames?: number;
style?: React.CSSProperties;
}> = ({ text, delay = 0, staggerFrames = 4, style }) => {
const frame = useCurrentFrame();
return (
<span style={{ display: "flex", flexWrap: "wrap", gap: "0.3em", ...style }}>
{text.split(" ").map((word, i) => {
const wordDelay = delay + i * staggerFrames;
const progress = spring({
frame: frame - wordDelay, fps: 30,
config: { damping: 18, stiffness: 90 },
});
return (
<span key={i} style={{
opacity: progress,
transform: `translateY(${(1 - progress) * 15}px)`,
display: "inline-block",
}}>
{word}
</span>
);
})}
</span>
);
};FilmGrain — Noise Texture Overlay
Every premium video needs 2-4% grain:
import React from "react";
import { useCurrentFrame } from "remotion";
export const FilmGrain: React.FC<{ opacity?: number }> = ({ opacity = 0.03 }) => {
const frame = useCurrentFrame();
const seed = Math.floor(frame * 1.7); // different noise per frame
return (
<div style={{
position: "absolute", inset: 0, pointerEvents: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' seed='${seed}'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E")`,
opacity,
mixBlendMode: "overlay",
}} />
);
};MaskedReveal — Clip-Path Wipe Transition
import React from "react";
import { useCurrentFrame, interpolate, Easing } from "remotion";
export const MaskedReveal: React.FC<{
children: React.ReactNode;
delay?: number;
duration?: number;
direction?: "left" | "right" | "up" | "down";
}> = ({ children, delay = 0, duration = 30, direction = "left" }) => {
const frame = useCurrentFrame();
const progress = interpolate(frame - delay, [0, duration], [0, 100], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const clipPaths: Record<string, string> = {
left: `inset(0 ${100 - progress}% 0 0)`,
right: `inset(0 0 0 ${100 - progress}%)`,
up: `inset(${100 - progress}% 0 0 0)`,
down: `inset(0 0 ${100 - progress}% 0)`,
};
return (
<div style={{ clipPath: clipPaths[direction] }}>
{children}
</div>
);
};AnimatedPath — SVG Self-Drawing with Glow
Uses strokeDasharray/strokeDashoffset for path reveal animation:
export const AnimatedPath: React.FC<{
d: string;
stroke?: string;
strokeWidth?: number;
delay?: number;
duration?: number;
width?: number;
height?: number;
viewBox?: string;
}> = ({
d, stroke = "#10A37F", strokeWidth = 2,
delay = 0, duration = 60,
width = 400, height = 400, viewBox = "0 0 400 400",
}) => {
const frame = useCurrentFrame();
const progress = interpolate(frame - delay, [0, duration], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
const pathLen = 2000;
const dashOffset = pathLen * (1 - progress);
return (
<svg width={width} height={height} viewBox={viewBox}
style={{ position: "absolute", overflow: "visible" }}>
<defs>
<filter id="pathGlow">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<path d={d} stroke={stroke} strokeWidth={strokeWidth} fill="none"
strokeLinecap="round" strokeDasharray={pathLen} strokeDashoffset={dashOffset}
filter="url(#pathGlow)"
style={{ filter: `drop-shadow(0 0 6px ${stroke}40)` }} />
</svg>
);
};FloatingOrb — Drifting Gradient Sphere Accent
Ambient decoration behind content. Use at 4-8% opacity for subtle depth:
export const FloatingOrb: React.FC<{
color: string; size?: number;
x?: number; y?: number; speed?: number; opacity?: number;
}> = ({ color, size = 200, x = 0, y = 0, speed = 120, opacity = 0.08 }) => {
const frame = useCurrentFrame();
const dx = interpolate(Math.sin(frame / speed), [-1, 1], [-30, 30]);
const dy = interpolate(Math.cos(frame / (speed * 0.7)), [-1, 1], [-20, 20]);
const scale = interpolate(Math.sin(frame / (speed * 1.5)), [-1, 1], [0.9, 1.1]);
return (
<div style={{
position: "absolute", left: x, top: y, width: size, height: size,
borderRadius: "50%",
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
opacity,
transform: `translate(${dx}px, ${dy}px) scale(${scale})`,
filter: "blur(40px)", pointerEvents: "none",
}} />
);
};CSS ParticleField — Converging Dots (Lottie Alternative)
When premium Lottie files aren't available, this CSS-based system looks better than placeholder JSON:
const PARTICLES = Array.from({ length: 12 }, (_, i) => {
const angle = (i / 12) * Math.PI * 2;
const radius = 200 + (i % 3) * 80;
return {
startX: Math.cos(angle) * radius,
startY: Math.sin(angle) * radius,
size: 3 + (i % 4) * 2,
delay: i * 3,
opacity: 0.15 + (i % 3) * 0.1,
};
});
const ParticleField: React.FC = () => {
const frame = useCurrentFrame();
return (
<>
{PARTICLES.map((p, i) => {
const progress = interpolate(frame - p.delay, [0, 50], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const x = p.startX * (1 - progress);
const y = p.startY * (1 - progress);
return (
<div key={i} style={{
position: "absolute", left: "50%", top: "50%",
width: p.size, height: p.size, borderRadius: "50%",
background: accentColor,
opacity: p.opacity * progress,
transform: `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`,
boxShadow: `0 0 ${p.size * 3}px ${accentColor}40`,
}} />
);
})}
</>
);
};[PROTOCOL]: Update this file when animation components change, then check CLAUDE.md
Audio Integration Patterns
Audio sync, beat detection, voiceover ducking, and VO timeline systems for Remotion videos.
Beat Sync System
Align scene transitions and animations to music beats for rhythmic, polished videos.
beats.ts — Beat Timestamp Array
Generate beat timestamps from your track's BPM:
// For 125 BPM: beat interval = 60/125 = 0.48s
export const BEATS: number[] = [
0.48, 0.96, 1.44, 1.92, 2.40, 2.88, 3.36, 3.84,
// ... generate full array for track duration
];
export const isOnBeat = (timeSec: number, toleranceSec = 0.05): boolean =>
BEATS.some(b => Math.abs(timeSec - b) <= toleranceSec);
export const isOnStrongBeat = (timeSec: number, toleranceSec = 0.05): boolean =>
BEATS.filter((_, i) => i % 4 === 0).some(b => Math.abs(timeSec - b) <= toleranceSec);
export const getBeatProgress = (timeSec: number): number => {
const beatInterval = 60 / 125; // adjust to your BPM
return (timeSec % beatInterval) / beatInterval;
};Using Beats in Components
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentSec = frame / fps;
// Pulse on beat
const beatPulse = isOnBeat(currentSec)
? spring({ frame: frame % 15, fps, config: { damping: 8, stiffness: 200 } })
: 0;
<div style={{ transform: `scale(${1 + beatPulse * 0.05})` }}>
{children}
</div>BGM Auto-Duck
Lower background music volume when voiceover is playing.
import { Audio, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
interface VoSegment {
start: number; // seconds
duration: number; // seconds
file: string;
}
const BGMWithDuck: React.FC<{
musicFile: string;
voSegments: VoSegment[];
baseVolume?: number;
duckVolume?: number;
fadeDuration?: number; // frames for duck transition
}> = ({
musicFile, voSegments,
baseVolume = 0.7, duckVolume = 0.15, fadeDuration = 8,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentSec = frame / fps;
const isVoActive = voSegments.some(
seg => currentSec >= seg.start && currentSec < seg.start + seg.duration
);
// Smooth transition between ducked and full volume
const volume = isVoActive ? duckVolume : baseVolume;
return <Audio src={staticFile(musicFile)} volume={volume} />;
};Advanced Duck with Edge Fade
Smoothly ramp volume at VO boundaries instead of hard cuts:
const computeDuckedVolume = (
frame: number, fps: number,
voSegments: VoSegment[],
baseVol: number, duckVol: number, fadeFrames: number,
): number => {
const sec = frame / fps;
for (const seg of voSegments) {
const segEnd = seg.start + seg.duration;
// Inside VO segment — check edge fade
if (sec >= seg.start && sec < segEnd) {
const fadeInProgress = interpolate(
sec, [seg.start, seg.start + fadeFrames / fps], [baseVol, duckVol],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const fadeOutProgress = interpolate(
sec, [segEnd - fadeFrames / fps, segEnd], [duckVol, baseVol],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return Math.min(fadeInProgress, fadeOutProgress);
}
}
return baseVol;
};Voiceover Timeline with Nudge Adjustment
Fine-tune VO segment timing without re-recording:
type VoDefaults = Record<string, { start: number; duration: number; file: string }>;
type VoNudges = Record<string, number>; // seconds to shift each segment
const resolveVoSegments = (
defaults: VoDefaults,
nudges: VoNudges = {},
): VoSegment[] =>
Object.entries(defaults).map(([key, seg]) => ({
...seg,
start: Math.max(0, seg.start + (nudges[key] ?? 0)),
}));
// Usage in Root.tsx schema:
const videoPropsSchema = z.object({
voEnabled: z.boolean().default(true),
voNudges: z.record(z.string(), z.number()).default({}),
// ...
});
// Apply nudges
const voSegments = resolveVoSegments(VO_DEFAULTS, props.voNudges);Whisper Word-Level Sync
For transcript-driven videos with precise word highlighting:
# Generate word timestamps
whisper audio.wav --model medium --language en --word_timestamps True --output_format jsonParse Whisper Output
// Build per-segment word timing arrays
type WordTimes = Record<number, number[]>; // segmentId -> relative seconds per word
const WORD_TIMES: WordTimes = {
0: [0.0, 0.28, 0.56, 0.89, ...], // relative to segment start
1: [0.0, 0.34, 0.72, ...],
// ...
};
// Highlight current word in transcript
const getCurrentWordIndex = (
segmentId: number, elapsedSec: number,
): number => {
const times = WORD_TIMES[segmentId];
if (!times) return -1;
for (let i = times.length - 1; i >= 0; i--) {
if (elapsedSec >= times[i]) return i;
}
return 0;
};Audio Layer Composition
Standard three-layer audio setup:
<AbsoluteFill>
{/* Layer 1: Background Music (always playing, auto-ducked) */}
<Audio src={staticFile("bgm.mp3")} volume={bgmVolume} />
{/* Layer 2: Voiceover segments (timed to scenes) */}
{voSegments.map((seg, i) => (
<Sequence key={i} from={Math.round(seg.start * fps)}
durationInFrames={Math.round(seg.duration * fps)}>
<Audio src={staticFile(seg.file)} volume={1.0} />
</Sequence>
))}
{/* Layer 3: SFX (whoosh on transitions, click on UI, etc.) */}
<Sequence from={transitionFrame} durationInFrames={15}>
<Audio src={staticFile("whoosh.mp3")} volume={0.4} />
</Sequence>
</AbsoluteFill>[PROTOCOL]: Update this file when audio patterns change, then check CLAUDE.md
Visual Components
Reusable visual components for premium Remotion videos. Copy into your project's components/ directory.
HARD RULES
1. lucide-react for all icons. Never use emoji. Import and render as React components. 2. No circle pulse / breathing rings. Use GradientMesh, SVG path-draw, or masked reveals. 3. Colors from scraped brand site. Never invent. Always extract from the real page first.
GradientMesh — Animated Gradient Background
Replaces FluidBackground with a more sophisticated multi-stop gradient that drifts:
import React from "react";
import { useCurrentFrame, interpolate } from "remotion";
interface GradientMeshProps {
colors: [string, string];
speed?: number;
opacity?: number;
}
export const GradientMesh: React.FC<GradientMeshProps> = ({
colors, speed = 150, opacity = 0.12,
}) => {
const frame = useCurrentFrame();
// Three anchor points that drift independently
const x1 = interpolate(Math.sin(frame / speed), [-1, 1], [20, 50]);
const y1 = interpolate(Math.cos(frame / (speed * 0.8)), [-1, 1], [10, 40]);
const x2 = interpolate(Math.sin(frame / (speed * 1.2) + 1.5), [-1, 1], [50, 80]);
const y2 = interpolate(Math.cos(frame / (speed * 0.6) + 0.8), [-1, 1], [60, 90]);
const x3 = interpolate(Math.sin(frame / (speed * 0.9) + 3), [-1, 1], [30, 70]);
const y3 = interpolate(Math.cos(frame / (speed * 1.1) + 2), [-1, 1], [40, 80]);
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden" }}>
<div style={{
position: "absolute", inset: "-50%", width: "200%", height: "200%",
background: `
radial-gradient(ellipse 600px 600px at ${x1}% ${y1}%, ${colors[0]}${Math.round(opacity * 255).toString(16).padStart(2, "0")} 0%, transparent 70%),
radial-gradient(ellipse 500px 500px at ${x2}% ${y2}%, ${colors[1]}${Math.round(opacity * 255).toString(16).padStart(2, "0")} 0%, transparent 70%),
radial-gradient(ellipse 400px 400px at ${x3}% ${y3}%, ${colors[0]}${Math.round(opacity * 0.6 * 255).toString(16).padStart(2, "0")} 0%, transparent 70%)
`,
filter: "blur(60px)",
}} />
</div>
);
};GlassPanel — Frosted Glass Card
import React from "react";
interface GlassPanelProps {
children: React.ReactNode;
padding?: number;
borderRadius?: number;
blur?: number;
opacity?: number;
style?: React.CSSProperties;
}
export const GlassPanel: React.FC<GlassPanelProps> = ({
children, padding = 40, borderRadius = 24,
blur = 20, opacity = 0.06, style,
}) => (
<div style={{
background: `linear-gradient(135deg, rgba(255,255,255,${opacity}), rgba(255,255,255,${opacity * 0.15}))`,
backdropFilter: `blur(${blur}px)`,
WebkitBackdropFilter: `blur(${blur}px)`,
borderRadius,
border: "1px solid rgba(255,255,255,0.08)",
boxShadow: "0 8px 32px rgba(0,0,0,0.37)",
padding,
...style,
}}>
{children}
</div>
);GridOverlay — Subtle Grid Pattern
import React from "react";
export const GridOverlay: React.FC<{
size?: number;
opacity?: number;
color?: string;
}> = ({ size = 40, opacity = 0.04, color = "white" }) => (
<div style={{
position: "absolute", inset: 0,
backgroundImage: `
linear-gradient(${color} 1px, transparent 1px),
linear-gradient(90deg, ${color} 1px, transparent 1px)
`,
backgroundSize: `${size}px ${size}px`,
opacity,
pointerEvents: "none",
}} />
);BrandIcon — lucide-react in Branded Container
import React from "react";
import { type LucideIcon } from "lucide-react";
interface BrandIconProps {
icon: LucideIcon;
color: string;
size?: number;
containerSize?: number;
borderRadius?: number;
}
export const BrandIcon: React.FC<BrandIconProps> = ({
icon: Icon, color, size = 32, containerSize = 64, borderRadius = 16,
}) => (
<div style={{
width: containerSize, height: containerSize,
display: "flex", alignItems: "center", justifyContent: "center",
background: `${color}12`,
border: `1px solid ${color}20`,
borderRadius,
}}>
<Icon size={size} color={color} strokeWidth={1.5} />
</div>
);Usage:
import { Brain, Zap, Shield, Eye, Wrench, Rocket, Sparkles, Globe, Lock, BarChart3 } from "lucide-react";
import { BrandIcon } from "./Icons";
// In a feature card
<BrandIcon icon={Brain} color={theme.color.primary} />
<BrandIcon icon={Zap} color={theme.color.accent} size={48} containerSize={80} />
// Common icon mappings for tech products:
// AI/ML: Brain, Sparkles Speed/Perf: Zap, Gauge
// Security: Shield, Lock Vision: Eye, ScanEye
// Tools/API: Wrench, Code2 Launch: Rocket, ArrowUpRight
// Data: BarChart3, TrendingUp Global: Globe, NetworkGradientBar — Brand Gradient Accent
import React from "react";
export const GradientBar: React.FC<{
colors: string[];
height?: number;
position?: "top" | "bottom";
}> = ({ colors, height = 4, position = "top" }) => (
<div style={{
position: "absolute",
left: 0, right: 0,
[position]: 0,
height,
background: `linear-gradient(90deg, ${colors.join(", ")})`,
}} />
);GradientText — Headline with Brand Gradient
import React from "react";
export const GradientText: React.FC<{
children: React.ReactNode;
colors: [string, string];
angle?: number;
style?: React.CSSProperties;
}> = ({ children, colors, angle = 135, style }) => (
<span style={{
background: `linear-gradient(${angle}deg, ${colors[0]}, ${colors[1]})`,
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
...style,
}}>
{children}
</span>
);ProductFrame — Screenshot in Perspective
import React from "react";
import { Img, staticFile } from "remotion";
export const ProductFrame: React.FC<{
src: string;
shadow?: boolean;
perspective?: number;
rotateY?: number;
}> = ({ src, shadow = true, perspective = 1200, rotateY = -8 }) => (
<div style={{
perspective,
display: "flex", justifyContent: "center",
}}>
<Img src={staticFile(src)} style={{
maxWidth: "100%",
borderRadius: 16,
transform: `rotateY(${rotateY}deg)`,
boxShadow: shadow
? "0 24px 80px rgba(0,0,0,0.4), 0 8px 24px rgba(0,0,0,0.2)"
: "none",
}} />
</div>
);Layouts
CenteredLayout
import React from "react";
export const CenteredLayout: React.FC<{
children: React.ReactNode;
maxWidth?: number;
gap?: number;
}> = ({ children, maxWidth = 1200, gap = 24 }) => (
<div style={{
position: "absolute", inset: 0,
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
gap, padding: 80,
}}>
<div style={{ maxWidth, width: "100%", textAlign: "center" }}>
{children}
</div>
</div>
);SplitLayout — Media + Text Side-by-Side
import React from "react";
export const SplitLayout: React.FC<{
left: React.ReactNode;
right: React.ReactNode;
ratio?: number;
gap?: number;
}> = ({ left, right, ratio = 0.5, gap = 60 }) => (
<div style={{
position: "absolute", inset: 0,
display: "flex", alignItems: "center",
padding: 80, gap,
}}>
<div style={{ flex: `0 0 ${ratio * 100}%` }}>{left}</div>
<div style={{ flex: 1 }}>{right}</div>
</div>
);iOS Notification Overlay (for demo videos)
import React from "react";
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
export const NotificationOverlay: React.FC<{
text: string;
startSec: number;
endSec: number;
icon?: React.ReactNode;
width?: string;
}> = ({ text, startSec, endSec, icon, width = "38%" }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const sec = frame / fps;
const dur = endSec - startSec;
const t = sec - startSec;
if (sec < startSec || sec > endSec) return null;
const slideIn = interpolate(t, [0, 0.4], [-120, 0], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
const slideOut = interpolate(t, [dur - 0.4, dur], [0, -120], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
return (
<div style={{
position: "absolute", top: 16, left: 16,
width, transform: `translateY(${slideIn + slideOut}px)`,
background: "linear-gradient(135deg, #2a2a2e 0%, #1c1c1e 100%)",
borderRadius: 16, padding: "14px 18px",
display: "flex", alignItems: "center", gap: 14,
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
border: "1px solid rgba(255,255,255,0.08)",
}}>
{icon}
<span style={{
color: "white", fontSize: 26, fontWeight: 500,
fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
}}>
{text}
</span>
</div>
);
};Transition Presets
import { slide } from "@remotion/transitions/slide";
import { fade } from "@remotion/transitions/fade";
import { springTiming, linearTiming } from "@remotion/transitions";
export const transitions = {
slideLeft: { presentation: slide({ direction: "from-left" }), timing: springTiming({ config: { damping: 100 } }) },
slideRight: { presentation: slide({ direction: "from-right" }), timing: springTiming({ config: { damping: 100 } }) },
slideUp: { presentation: slide({ direction: "from-bottom" }), timing: springTiming({ config: { damping: 100 } }) },
fade: { presentation: fade(), timing: linearTiming({ durationInFrames: 30 }) },
quickFade: { presentation: fade(), timing: linearTiming({ durationInFrames: 15 }) },
};[PROTOCOL]: Update this file when visual components change, then check CLAUDE.md
Lottie & GSAP Integration
AE-quality animations in Remotion via @remotion/lottie and GSAP timeline sync.
@remotion/lottie — Core Integration
Install:
npm i @remotion/lottie lottie-webLottieAnimation wrapper component:
import React, { useEffect, useState } from "react";
import { Lottie, LottieAnimationData } from "@remotion/lottie";
import { cancelRender, continueRender, delayRender, staticFile } from "remotion";
interface LottieAnimationProps {
file: string; // path relative to public/
loop?: boolean;
playbackRate?: number;
direction?: "forward" | "backward";
style?: React.CSSProperties;
}
export const LottieAnimation: React.FC<LottieAnimationProps> = ({
file, loop = false, playbackRate = 1, direction = "forward", style,
}) => {
const [handle] = useState(() => delayRender(`Loading Lottie: ${file}`));
const [data, setData] = useState<LottieAnimationData | null>(null);
useEffect(() => {
fetch(staticFile(file))
.then(r => r.json())
.then(json => {
setData(json);
continueRender(handle);
})
.catch(err => cancelRender(err));
}, [handle, file]);
if (!data) return null;
return (
<Lottie
animationData={data}
loop={loop}
playbackRate={playbackRate}
direction={direction}
style={{ width: "100%", height: "100%", ...style }}
/>
);
};Usage in scenes:
// Logo reveal with particle animation
<AbsoluteFill>
<LottieAnimation file="lottie/particle-converge.json" />
</AbsoluteFill>
// Background ambient effect
<div style={{ position: "absolute", inset: 0, opacity: 0.3 }}>
<LottieAnimation file="lottie/aurora-bg.json" loop />
</div>
// Transition between scenes
<Sequence from={transitionStart} durationInFrames={30}>
<LottieAnimation file="lottie/liquid-wipe.json" playbackRate={1.5} />
</Sequence>Lottie Props Reference
| Prop | Type | Default | Purpose |
|---|---|---|---|
animationData | object | required | Lottie JSON (memoize with useState) |
loop | boolean | false | Repeat animation |
playbackRate | number | 1 | Speed multiplier |
direction | "forward"/"backward" | "forward" | Play direction |
renderer | "svg"/"canvas"/"html" | "svg" | Render mode (svg = best quality) |
style | CSSProperties | — | Container styling |
className | string | — | Container class |
Critical: Always use delayRender() / continueRender() pattern. Remotion will not wait for async loads otherwise, causing blank frames during render.
Where to Get Premium Lottie Files
For product launch videos, you need these categories:
Logo/Reveal animations:
- LottieFiles marketplace: search "particle converge", "logo reveal", "morphing shapes"
- Creattie.com: artist-made premium animations
Transition effects:
- Search "liquid transition", "geometric morph", "light sweep", "glitch"
- These replace hard cuts between scenes
Data visualization:
- Search "animated chart", "counter", "graph", "network", "node"
- Use in Proof scene (Act 4) for metric reveals
Background/Ambient:
- Search "aurora", "particles floating", "gradient mesh animated", "abstract lines"
- Layer at low opacity (0.2-0.4) behind content
Tech-specific:
- "AI brain", "neural network", "code typing", "terminal", "API flow"
- Map to product category
GSAP Integration (Advanced)
For effects not available in Lottie, use GSAP with paused timeline + seek:
import gsap from "gsap";
import { useCurrentFrame, useVideoConfig } from "remotion";
import { useEffect, useRef } from "react";
const GsapScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const ref = useRef<HTMLDivElement>(null);
const tl = useRef<gsap.core.Timeline>();
// Build timeline once
useEffect(() => {
if (!ref.current) return;
tl.current = gsap.timeline({ paused: true })
.from(ref.current, { x: -200, opacity: 0, duration: 0.8, ease: "power3.out" })
.to(ref.current, { scale: 1.05, duration: 0.3, ease: "power2.inOut" }, "+=0.2")
.to(ref.current, { scale: 1, duration: 0.2, ease: "power2.out" });
}, []);
// Seek to current frame position
useEffect(() => {
tl.current?.seek(frame / fps);
}, [frame, fps]);
return <div ref={ref}>Animated Element</div>;
};When to use GSAP vs Remotion native:
- GSAP: Complex timeline choreography, MorphSVG, DrawSVG, SplitText (GSAP plugin)
- Remotion: Simple spring/interpolate animations, audio sync, frame-accurate control
Install: npm i gsap (free core). Premium plugins (MorphSVG, DrawSVG) require GSAP license.
Rive Integration (Alternative to Lottie)
For interactive/parametric animations:
npm i @remotion/riveimport { RemotionRiveCanvas } from "@remotion/rive";
import { staticFile } from "remotion";
<RemotionRiveCanvas
src={staticFile("animation.riv")}
fit="contain"
alignment="center"
/>Rive advantage: state machines, dynamic text, interactive. Use when animation needs to respond to data (e.g., dynamic brand name in animated title).
[PROTOCOL]: Update this file when Lottie/GSAP patterns change, then check CLAUDE.md
Visual Effects Library Reference
Confirmed-working libraries for advanced visual effects in Remotion videos.
CRITICAL: Remotion renders frame-by-frame. Libraries using browser timing APIs (requestAnimationFrame, performance.now(), CSS transitions) will flicker or break. Only use libraries confirmed to work with useCurrentFrame().
Tier 1: Official Remotion Packages (Install & Use)
@remotion/lottie — Lottie Animations
npm i @remotion/lottie lottie-webPlay Lottie JSON animations synchronized with timeline. Use for: logo animations, loading spinners, micro-interactions, icon animations, celebration effects.
import { Lottie, getLottieMetadata } from "@remotion/lottie";
import animationData from "./animation.json";
const metadata = getLottieMetadata(animationData);
<Lottie
animationData={animationData}
playbackRate={1}
style={{ width: 200, height: 200 }}
/>Free Lottie sources:
- LottieFiles: https://lottiefiles.com (largest library)
- LordIcon: https://lordicon.com (animated icons)
- useAnimations: https://useanimations.com (micro-animations)
@remotion/motion-blur — Film Motion Blur
npm i @remotion/motion-blurimport { CameraMotionBlur, Trail } from "@remotion/motion-blur";
// Natural film-camera motion blur
<CameraMotionBlur samples={10}>
<MyAnimatedScene />
</CameraMotionBlur>
// Trailing duplicates with time offset
<Trail layers={6} lagInFrames={2}>
<MyMovingElement />
</Trail>Use for: fast-moving elements, scene transitions, premium kinetic feel.
@remotion/paths — SVG Path Animation
Already in scaffold. Key functions:
import { evolvePath, interpolatePath } from "@remotion/paths";
// Line drawing effect (path gradually appears)
const progress = interpolate(frame, [0, 60], [0, 1], { extrapolateRight: "clamp" });
const evolved = evolvePath(progress, svgPathData);
<path d={evolved} stroke={color} fill="none" />
// Morph between two SVG shapes
const morphed = interpolatePath(progress, pathA, pathB);Use for: chart line drawing, logo path reveal, icon morphing.
@remotion/noise — Perlin Noise
Already in scaffold. Use for organic, natural randomness:
import { noise2D, noise3D } from "@remotion/noise";
// Floating particle positions
const x = noise2D("seed-x", frame / 100, 0) * 200;
const y = noise2D("seed-y", 0, frame / 100) * 200;Tier 2: Confirmed Community Libraries
remotion-confetti — Confetti Bursts
npm i remotion-confettiCanvas-based confetti. Use for: celebration moments, achievement reveals, proof scene.
remotion-animated — Declarative Animation Chains
npm i remotion-animatedimport { Animated, Move, Scale, Fade } from "remotion-animated";
<Animated animations={[
Move({ y: 0, start: -50 }),
Scale({ by: 1, initial: 0.8 }),
Fade({ to: 1 }),
]}>
<MyComponent />
</Animated>Use for: cleaner animation code, chaining multiple effects.
Flubber — SVG Shape Morphing
npm i flubberimport { interpolate as flubberInterpolate } from "flubber";
const morpher = flubberInterpolate(circlePath, starPath);
const d = morpher(progress); // 0→1
<path d={d} />Use for: icon transitions, shape-shifting logos.
Tier 3: Advanced (Heavier, Use Sparingly)
@remotion/three — 3D via React Three Fiber
npm i three @react-three/fiber @remotion/three @types/threeFull 3D: models (GLTF), 3D text, particles, shaders, post-processing.
import { ThreeCanvas } from "@remotion/three";
<ThreeCanvas width={1920} height={1080}>
<mesh><boxGeometry /><meshStandardMaterial /></mesh>
</ThreeCanvas>Gotchas: Use useCurrentFrame() not R3F's useFrame(). <Sequence layout="none"> inside canvas. Heavy render time.
@react-three/postprocessing — Bloom, Glitch, Chromatic Aberration
npm i @react-three/postprocessing postprocessingWorks inside <ThreeCanvas>. Effects: Bloom, ChromaticAberration, Glitch, DepthOfField, GodRays, Noise, Vignette, Scanlines.
GL Transitions — 80+ GLSL Transitions
Repo: https://github.com/remotion-dev/remotion-gl-transitions Collection: https://gl-transitions.com/ Beyond fade: cube rotate, directional wipe, swap, mosaic, pixelize, burn, ripple.
DO NOT USE (Incompatible)
| Library | Why |
|---|---|
| Framer Motion / motion | Uses performance.now(), flickers during render |
| react-spring | Conflicts with Remotion's spring(), timing issues |
| tsParticles | Internal animation loop, no frame sync |
| CSS transitions/animations | Not deterministic per-frame |
Recommended Additions to Scaffold
For maximum visual punch with minimal complexity, add these to every project: 1. @remotion/lottie + lottie-web — drop-in animated icons/effects 2. @remotion/motion-blur — premium kinetic feel on fast animations 3. remotion-confetti — celebration/proof moments 4. flubber — SVG morphing for transitions
The scaffold already includes: @remotion/paths, @remotion/noise, @remotion/transitions, @remotion/light-leaks.
Using Three.js and React Three Fiber in Remotion
Follow React Three Fiber and Three.js best practices. Only the following Remotion-specific rules need to be followed:
Prerequisites
First, the @remotion/three package needs to be installed. If it is not, use the following command:
npx remotion add @remotion/three # If project uses npm
bunx remotion add @remotion/three # If project uses bun
yarn remotion add @remotion/three # If project uses yarn
pnpm exec remotion add @remotion/three # If project uses pnpmUsing ThreeCanvas
You MUST wrap 3D content in <ThreeCanvas> and include proper lighting. <ThreeCanvas> MUST have a width and height prop.
import { ThreeCanvas } from "@remotion/three";
import { useVideoConfig } from "remotion";
const { width, height } = useVideoConfig();
<ThreeCanvas width={width} height={height}>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} intensity={0.8} />
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial color="red" />
</mesh>
</ThreeCanvas>;No animations not driven by useCurrentFrame()
Shaders, models etc MUST NOT animate by themselves. No animations are allowed unless they are driven by useCurrentFrame(). Otherwise, it will cause flickering during rendering.
Using useFrame() from @react-three/fiber is forbidden.
Animate using useCurrentFrame()
Use useCurrentFrame() to perform animations.
const frame = useCurrentFrame();
const rotationY = frame * 0.02;
<mesh rotation={[0, rotationY, 0]}>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#4a9eff" />
</mesh>;Using <Sequence> inside <ThreeCanvas>
The layout prop of any <Sequence> inside a <ThreeCanvas> must be set to none.
import { Sequence } from "remotion";
import { ThreeCanvas } from "@remotion/three";
const { width, height } = useVideoConfig();
<ThreeCanvas width={width} height={height}>
<Sequence layout="none">
<mesh>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#4a9eff" />
</mesh>
</Sequence>
</ThreeCanvas>;All animations MUST be driven by the useCurrentFrame() hook. Write animations in seconds and multiply them by the fps value from useVideoConfig().
import { useCurrentFrame } from "remotion";
export const FadeIn = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame, [0, 2 * fps], [0, 1], {
extrapolateRight: "clamp",
});
return <div style={{ opacity }}>Hello World!</div>;
};CSS transitions or animations are FORBIDDEN - they will not render correctly. Tailwind animation class names are FORBIDDEN - they will not render correctly.
Importing assets in Remotion
The public folder
Place assets in the public/ folder at your project root.
Using staticFile()
You MUST use staticFile() to reference files from the public/ folder:
import { Img, staticFile } from "remotion";
export const MyComposition = () => {
return <Img src={staticFile("logo.png")} />;
};The function returns an encoded URL that works correctly when deploying to subdirectories.
Using with components
Images:
import { Img, staticFile } from "remotion";
<Img src={staticFile("photo.png")} />;Videos:
import { Video } from "@remotion/media";
import { staticFile } from "remotion";
<Video src={staticFile("clip.mp4")} />;Audio:
import { Audio } from "@remotion/media";
import { staticFile } from "remotion";
<Audio src={staticFile("music.mp3")} />;Fonts:
import { staticFile } from "remotion";
const fontFamily = new FontFace("MyFont", `url(${staticFile("font.woff2")})`);
await fontFamily.load();
document.fonts.add(fontFamily);Remote URLs
Remote URLs can be used directly without staticFile():
<Img src="https://example.com/image.png" />
<Video src="https://remotion.media/video.mp4" />Important notes
- Remotion components (
<Img>,<Video>,<Audio>) ensure assets are fully loaded before rendering - Special characters in filenames (
#,?,&) are automatically encoded
import {loadFont} from '@remotion/google-fonts/Inter';
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
const {fontFamily} = loadFont();
const COLOR_BAR = '#D4AF37';
const COLOR_TEXT = '#ffffff';
const COLOR_MUTED = '#888888';
const COLOR_BG = '#0a0a0a';
const COLOR_AXIS = '#333333';
// Ideal composition size: 1280x720
const Title: React.FC<{children: React.ReactNode}> = ({children}) => (
<div style={{textAlign: 'center', marginBottom: 40}}>
<div style={{color: COLOR_TEXT, fontSize: 48, fontWeight: 600}}>
{children}
</div>
</div>
);
const YAxis: React.FC<{steps: number[]; height: number}> = ({
steps,
height,
}) => (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height,
paddingRight: 16,
}}
>
{steps
.slice()
.reverse()
.map((step) => (
<div
key={step}
style={{
color: COLOR_MUTED,
fontSize: 20,
textAlign: 'right',
}}
>
{step.toLocaleString()}
</div>
))}
</div>
);
const Bar: React.FC<{
height: number;
progress: number;
}> = ({height, progress}) => (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<div
style={{
width: '100%',
height,
backgroundColor: COLOR_BAR,
borderRadius: '8px 8px 0 0',
opacity: progress,
}}
/>
</div>
);
const XAxis: React.FC<{
children: React.ReactNode;
labels: string[];
height: number;
}> = ({children, labels, height}) => (
<div style={{flex: 1, display: 'flex', flexDirection: 'column'}}>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 16,
height,
borderLeft: `2px solid ${COLOR_AXIS}`,
borderBottom: `2px solid ${COLOR_AXIS}`,
paddingLeft: 16,
}}
>
{children}
</div>
<div
style={{
display: 'flex',
gap: 16,
paddingLeft: 16,
marginTop: 12,
}}
>
{labels.map((label) => (
<div
key={label}
style={{
flex: 1,
textAlign: 'center',
color: COLOR_MUTED,
fontSize: 20,
}}
>
{label}
</div>
))}
</div>
</div>
);
export const MyAnimation = () => {
const frame = useCurrentFrame();
const {fps, height} = useVideoConfig();
const data = [
{month: 'Jan', price: 2039},
{month: 'Mar', price: 2160},
{month: 'May', price: 2327},
{month: 'Jul', price: 2426},
{month: 'Sep', price: 2634},
{month: 'Nov', price: 2672},
];
const minPrice = 2000;
const maxPrice = 2800;
const priceRange = maxPrice - minPrice;
const chartHeight = height - 280;
const yAxisSteps = [2000, 2400, 2800];
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
padding: 60,
display: 'flex',
flexDirection: 'column',
fontFamily,
}}
>
<Title>Gold Price 2024</Title>
<div style={{display: 'flex', flex: 1}}>
<YAxis steps={yAxisSteps} height={chartHeight} />
<XAxis height={chartHeight} labels={data.map((d) => d.month)}>
{data.map((item, i) => {
const progress = spring({
frame: frame - i * 5 - 10,
fps,
config: {damping: 18, stiffness: 80},
});
const barHeight =
((item.price - minPrice) / priceRange) * chartHeight * progress;
return (
<Bar key={item.month} height={barHeight} progress={progress} />
);
})}
</XAxis>
</div>
</AbsoluteFill>
);
};
import {
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
const COLOR_BG = '#ffffff';
const COLOR_TEXT = '#000000';
const FULL_TEXT = 'From prompt to motion graphics. This is Remotion.';
const PAUSE_AFTER = 'From prompt to motion graphics.';
const FONT_SIZE = 72;
const FONT_WEIGHT = 700;
const CHAR_FRAMES = 2;
const CURSOR_BLINK_FRAMES = 16;
const PAUSE_SECONDS = 1;
// Ideal composition size: 1280x720
const getTypedText = ({
frame,
fullText,
pauseAfter,
charFrames,
pauseFrames,
}: {
frame: number;
fullText: string;
pauseAfter: string;
charFrames: number;
pauseFrames: number;
}): string => {
const pauseIndex = fullText.indexOf(pauseAfter);
const preLen =
pauseIndex >= 0 ? pauseIndex + pauseAfter.length : fullText.length;
let typedChars = 0;
if (frame < preLen * charFrames) {
typedChars = Math.floor(frame / charFrames);
} else if (frame < preLen * charFrames + pauseFrames) {
typedChars = preLen;
} else {
const postPhase = frame - preLen * charFrames - pauseFrames;
typedChars = Math.min(
fullText.length,
preLen + Math.floor(postPhase / charFrames),
);
}
return fullText.slice(0, typedChars);
};
const Cursor: React.FC<{
frame: number;
blinkFrames: number;
symbol?: string;
}> = ({frame, blinkFrames, symbol = '\u258C'}) => {
const opacity = interpolate(
frame % blinkFrames,
[0, blinkFrames / 2, blinkFrames],
[1, 0, 1],
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'},
);
return <span style={{opacity}}>{symbol}</span>;
};
export const MyAnimation = () => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const pauseFrames = Math.round(fps * PAUSE_SECONDS);
const typedText = getTypedText({
frame,
fullText: FULL_TEXT,
pauseAfter: PAUSE_AFTER,
charFrames: CHAR_FRAMES,
pauseFrames,
});
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
}}
>
<div
style={{
color: COLOR_TEXT,
fontSize: FONT_SIZE,
fontWeight: FONT_WEIGHT,
fontFamily: 'sans-serif',
}}
>
<span>{typedText}</span>
<Cursor frame={frame} blinkFrames={CURSOR_BLINK_FRAMES} />
</div>
</AbsoluteFill>
);
};
import {loadFont} from '@remotion/google-fonts/Inter';
import React from 'react';
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
/*
* Highlight a word in a sentence with a spring-animated wipe effect.
*/
// Ideal composition size: 1280x720
const COLOR_BG = '#ffffff';
const COLOR_TEXT = '#000000';
const COLOR_HIGHLIGHT = '#A7C7E7';
const FULL_TEXT = 'This is Remotion.';
const HIGHLIGHT_WORD = 'Remotion';
const FONT_SIZE = 72;
const FONT_WEIGHT = 700;
const HIGHLIGHT_START_FRAME = 30;
const HIGHLIGHT_WIPE_DURATION = 18;
const {fontFamily} = loadFont();
const Highlight: React.FC<{
word: string;
color: string;
delay: number;
durationInFrames: number;
}> = ({word, color, delay, durationInFrames}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const highlightProgress = spring({
fps,
frame,
config: {damping: 200},
delay,
durationInFrames,
});
const scaleX = Math.max(0, Math.min(1, highlightProgress));
return (
<span style={{position: 'relative', display: 'inline-block'}}>
<span
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: '1.05em',
transform: `translateY(-50%) scaleX(${scaleX})`,
transformOrigin: 'left center',
backgroundColor: color,
borderRadius: '0.18em',
zIndex: 0,
}}
/>
<span style={{position: 'relative', zIndex: 1}}>{word}</span>
</span>
);
};
export const MyAnimation = () => {
const highlightIndex = FULL_TEXT.indexOf(HIGHLIGHT_WORD);
const hasHighlight = highlightIndex >= 0;
const preText = hasHighlight ? FULL_TEXT.slice(0, highlightIndex) : FULL_TEXT;
const postText = hasHighlight
? FULL_TEXT.slice(highlightIndex + HIGHLIGHT_WORD.length)
: '';
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
alignItems: 'center',
justifyContent: 'center',
fontFamily,
}}
>
<div
style={{
color: COLOR_TEXT,
fontSize: FONT_SIZE,
fontWeight: FONT_WEIGHT,
}}
>
{hasHighlight ? (
<>
<span>{preText}</span>
<Highlight
word={HIGHLIGHT_WORD}
color={COLOR_HIGHLIGHT}
delay={HIGHLIGHT_START_FRAME}
durationInFrames={HIGHLIGHT_WIPE_DURATION}
/>
<span>{postText}</span>
</>
) : (
<span>{FULL_TEXT}</span>
)}
</div>
</AbsoluteFill>
);
};
Audio Visualization in Remotion
Prerequisites
npx remotion add @remotion/media-utilsLoading Audio Data
Use useWindowedAudioData() (https://www.remotion.dev/docs/use-windowed-audio-data) to load audio data:
import { useWindowedAudioData } from "@remotion/media-utils";
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
src: staticFile("podcast.wav"),
frame,
fps,
windowInSeconds: 30,
});Spectrum Bar Visualization
Use visualizeAudio() (https://www.remotion.dev/docs/visualize-audio) to get frequency data for bar charts:
import { useWindowedAudioData, visualizeAudio } from "@remotion/media-utils";
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
src: staticFile("music.mp3"),
frame,
fps,
windowInSeconds: 30,
});
if (!audioData) {
return null;
}
const frequencies = visualizeAudio({
fps,
frame,
audioData,
numberOfSamples: 256,
optimizeFor: "speed",
dataOffsetInSeconds,
});
return (
<div style={{ display: "flex", alignItems: "flex-end", height: 200 }}>
{frequencies.map((v, i) => (
<div
key={i}
style={{
flex: 1,
height: `${v * 100}%`,
backgroundColor: "#0b84f3",
margin: "0 1px",
}}
/>
))}
</div>
);numberOfSamplesmust be power of 2 (32, 64, 128, 256, 512, 1024)- Values range 0-1; left of array = bass, right = highs
- Use
optimizeFor: "speed"for Lambda or high sample counts
Important: When passing audioData to child components, also pass the frame from the parent. Do not call useCurrentFrame() in each child - this causes discontinuous visualization when children are inside <Sequence> with offsets.
Waveform Visualization
Use visualizeAudioWaveform() (https://www.remotion.dev/docs/media-utils/visualize-audio-waveform) with createSmoothSvgPath() (https://www.remotion.dev/docs/media-utils/create-smooth-svg-path) for oscilloscope-style displays:
import {
createSmoothSvgPath,
useWindowedAudioData,
visualizeAudioWaveform,
} from "@remotion/media-utils";
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { width, fps } = useVideoConfig();
const HEIGHT = 200;
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
src: staticFile("voice.wav"),
frame,
fps,
windowInSeconds: 30,
});
if (!audioData) {
return null;
}
const waveform = visualizeAudioWaveform({
fps,
frame,
audioData,
numberOfSamples: 256,
windowInSeconds: 0.5,
dataOffsetInSeconds,
});
const path = createSmoothSvgPath({
points: waveform.map((y, i) => ({
x: (i / (waveform.length - 1)) * width,
y: HEIGHT / 2 + (y * HEIGHT) / 2,
})),
});
return (
<svg width={width} height={HEIGHT}>
<path d={path} fill="none" stroke="#0b84f3" strokeWidth={2} />
</svg>
);Bass-Reactive Effects
Extract low frequencies for beat-reactive animations:
const frequencies = visualizeAudio({
fps,
frame,
audioData,
numberOfSamples: 128,
optimizeFor: "speed",
dataOffsetInSeconds,
});
const lowFrequencies = frequencies.slice(0, 32);
const bassIntensity =
lowFrequencies.reduce((sum, v) => sum + v, 0) / lowFrequencies.length;
const scale = 1 + bassIntensity * 0.5;
const opacity = Math.min(0.6, bassIntensity * 0.8);Volume-Based Waveform
Use getWaveformPortion() (https://www.remotion.dev/docs/get-waveform-portion) when you need simplified volume data instead of frequency spectrum:
import { getWaveformPortion } from "@remotion/media-utils";
import { useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeInSeconds = frame / fps;
const waveform = getWaveformPortion({
audioData,
startTimeInSeconds: currentTimeInSeconds,
durationInSeconds: 5,
numberOfSamples: 50,
});
// Returns array of { index, amplitude } objects (amplitude: 0-1)
waveform.map((bar) => (
<div key={bar.index} style={{ height: bar.amplitude * 100 }} />
));Postprocessing
Low frequencies naturally dominate. Apply logarithmic scaling for visual balance:
const minDb = -100;
const maxDb = -30;
const scaled = frequencies.map((value) => {
const db = 20 * Math.log10(value);
return (db - minDb) / (maxDb - minDb);
});Using audio in Remotion
Prerequisites
First, the @remotion/media package needs to be installed. If it is not installed, use the following command:
npx remotion add @remotion/mediaImporting Audio
Use <Audio> from @remotion/media to add audio to your composition.
import { Audio } from "@remotion/media";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Audio src={staticFile("audio.mp3")} />;
};Remote URLs are also supported:
<Audio src="https://remotion.media/audio.mp3" />By default, audio plays from the start, at full volume and full length. Multiple audio tracks can be layered by adding multiple <Audio> components.
Trimming
Use trimBefore and trimAfter to remove portions of the audio. Values are in frames.
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
trimBefore={2 * fps} // Skip the first 2 seconds
trimAfter={10 * fps} // End at the 10 second mark
/>
);The audio still starts playing at the beginning of the composition - only the specified portion is played.
Delaying
Wrap the audio in a <Sequence> to delay when it starts:
import { Sequence, staticFile } from "remotion";
import { Audio } from "@remotion/media";
const { fps } = useVideoConfig();
return (
<Sequence from={1 * fps}>
<Audio src={staticFile("audio.mp3")} />
</Sequence>
);The audio will start playing after 1 second.
Volume
Set a static volume (0 to 1):
<Audio src={staticFile("audio.mp3")} volume={0.5} />Or use a callback for dynamic volume based on the current frame:
import { interpolate } from "remotion";
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
volume={(f) =>
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
}
/>
);The value of f starts at 0 when the audio begins to play, not the composition frame.
Muting
Use muted to silence the audio. It can be set dynamically:
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
muted={frame >= 2 * fps && frame <= 4 * fps} // Mute between 2s and 4s
/>
);Speed
Use playbackRate to change the playback speed:
<Audio src={staticFile("audio.mp3")} playbackRate={2} /> {/* 2x speed */}
<Audio src={staticFile("audio.mp3")} playbackRate={0.5} /> {/* Half speed */}Reverse playback is not supported.
Looping
Use loop to loop the audio indefinitely:
<Audio src={staticFile("audio.mp3")} loop />Use loopVolumeCurveBehavior to control how the frame count behaves when looping:
"repeat": Frame count resets to 0 each loop (default)"extend": Frame count continues incrementing
<Audio
src={staticFile("audio.mp3")}
loop
loopVolumeCurveBehavior="extend"
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
/>Pitch
Use toneFrequency to adjust the pitch without affecting speed. Values range from 0.01 to 2:
<Audio
src={staticFile("audio.mp3")}
toneFrequency={1.5} // Higher pitch
/>
<Audio
src={staticFile("audio.mp3")}
toneFrequency={0.8} // Lower pitch
/>Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the <Player />.
Using calculateMetadata
Use calculateMetadata on a <Composition> to dynamically set duration, dimensions, and transform props before rendering.
<Composition
id="MyComp"
component={MyComponent}
durationInFrames={300}
fps={30}
width={1920}
height={1080}
defaultProps={{ videoSrc: "https://remotion.media/video.mp4" }}
calculateMetadata={calculateMetadata}
/>Setting duration based on a video
Use the `getVideoDuration` and `getVideoDimensions` skills to get the video duration and dimensions:
import { CalculateMetadataFunction } from "remotion";
import { getVideoDuration } from "./get-video-duration";
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const durationInSeconds = await getVideoDuration(props.videoSrc);
return {
durationInFrames: Math.ceil(durationInSeconds * 30),
};
};Matching dimensions of a video
Use the `getVideoDimensions` skill to get the video dimensions:
import { CalculateMetadataFunction } from "remotion";
import { getVideoDuration } from "./get-video-duration";
import { getVideoDimensions } from "./get-video-dimensions";
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const dimensions = await getVideoDimensions(props.videoSrc);
return {
width: dimensions.width,
height: dimensions.height,
};
};Setting duration based on multiple videos
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const metadataPromises = props.videos.map((video) =>
getVideoDuration(video.src),
);
const allMetadata = await Promise.all(metadataPromises);
const totalDuration = allMetadata.reduce(
(sum, durationInSeconds) => sum + durationInSeconds,
0,
);
return {
durationInFrames: Math.ceil(totalDuration * 30),
};
};Setting a default outName
Set the default output filename based on props:
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
return {
defaultOutName: `video-${props.id}.mp4`,
};
};Transforming props
Fetch data or transform props before rendering:
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
abortSignal,
}) => {
const response = await fetch(props.dataUrl, { signal: abortSignal });
const data = await response.json();
return {
props: {
...props,
fetchedData: data,
},
};
};The abortSignal cancels stale requests when props change in the Studio.
Return value
All fields are optional. Returned values override the <Composition> props:
durationInFrames: Number of frameswidth: Composition width in pixelsheight: Composition height in pixelsfps: Frames per secondprops: Transformed props passed to the componentdefaultOutName: Default output filenamedefaultCodec: Default codec for rendering
Checking if a video can be decoded
Use Mediabunny to check if a video can be decoded by the browser before attempting to play it.
The canDecode() function
This function can be copy-pasted into any project.
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const canDecode = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
try {
await input.getFormat();
} catch {
return false;
}
const videoTrack = await input.getPrimaryVideoTrack();
if (videoTrack && !(await videoTrack.canDecode())) {
return false;
}
const audioTrack = await input.getPrimaryAudioTrack();
if (audioTrack && !(await audioTrack.canDecode())) {
return false;
}
return true;
};Usage
const src = "https://remotion.media/video.mp4";
const isDecodable = await canDecode(src);
if (isDecodable) {
console.log("Video can be decoded");
} else {
console.log("Video cannot be decoded by this browser");
}Using with Blob
For file uploads or drag-and-drop, use BlobSource:
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
export const canDecodeBlob = async (blob: Blob) => {
const input = new Input({
formats: ALL_FORMATS,
source: new BlobSource(blob),
});
// Same validation logic as above
};Charts in Remotion
Create charts using React code - HTML, SVG, and D3.js are all supported.
Disable all animations from third party libraries - they cause flickering. Drive all animations from useCurrentFrame().
Bar Chart
const STAGGER_DELAY = 5;
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const bars = data.map((item, i) => {
const height = spring({
frame,
fps,
delay: i * STAGGER_DELAY,
config: { damping: 200 },
});
return <div style={{ height: height * item.value }} />;
});Pie Chart
Animate segments using stroke-dashoffset, starting from 12 o'clock:
const progress = interpolate(frame, [0, 100], [0, 1]);
const circumference = 2 * Math.PI * radius;
const segmentLength = (value / total) * circumference;
const offset = interpolate(progress, [0, 1], [segmentLength, 0]);
<circle
r={radius}
cx={center}
cy={center}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={`${segmentLength} ${circumference}`}
strokeDashoffset={offset}
transform={`rotate(-90 ${center} ${center})`}
/>;Line Chart / Path Animation
Use @remotion/paths for animating SVG paths (line charts, stock graphs, signatures).
Install: npx remotion add @remotion/paths Docs: https://remotion.dev/docs/paths.md
Convert data points to SVG path
type Point = { x: number; y: number };
const generateLinePath = (points: Point[]): string => {
if (points.length < 2) return "";
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
};Draw path with animation
import { evolvePath } from "@remotion/paths";
const path = "M 100 200 L 200 150 L 300 180 L 400 100";
const progress = interpolate(frame, [0, 2 * fps], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.quad),
});
const { strokeDasharray, strokeDashoffset } = evolvePath(progress, path);
<path
d={path}
fill="none"
stroke="#FF3232"
strokeWidth={4}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>;Follow path with marker/arrow
import {
getLength,
getPointAtLength,
getTangentAtLength,
} from "@remotion/paths";
const pathLength = getLength(path);
const point = getPointAtLength(path, progress * pathLength);
const tangent = getTangentAtLength(path, progress * pathLength);
const angle = Math.atan2(tangent.y, tangent.x);
<g
style={{
transform: `translate(${point.x}px, ${point.y}px) rotate(${angle}rad)`,
transformOrigin: "0 0",
}}
>
<polygon points="0,0 -20,-10 -20,10" fill="#FF3232" />
</g>;A <Composition> defines the component, width, height, fps and duration of a renderable video.
It normally is placed in the src/Root.tsx file.
import { Composition } from "remotion";
import { MyComposition } from "./MyComposition";
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100}
fps={30}
width={1080}
height={1080}
/>
);
};Default Props
Pass defaultProps to provide initial values for your component. Values must be JSON-serializable (Date, Map, Set, and staticFile() are supported).
import { Composition } from "remotion";
import { MyComposition, MyCompositionProps } from "./MyComposition";
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100}
fps={30}
width={1080}
height={1080}
defaultProps={
{
title: "Hello World",
color: "#ff0000",
} satisfies MyCompositionProps
}
/>
);
};Use type declarations for props rather than interface to ensure defaultProps type safety.
Folders
Use <Folder> to organize compositions in the sidebar. Folder names can only contain letters, numbers, and hyphens.
import { Composition, Folder } from "remotion";
export const RemotionRoot = () => {
return (
<>
<Folder name="Marketing">
<Composition id="Promo" /* ... */ />
<Composition id="Ad" /* ... */ />
</Folder>
<Folder name="Social">
<Folder name="Instagram">
<Composition id="Story" /* ... */ />
<Composition id="Reel" /* ... */ />
</Folder>
</Folder>
</>
);
};Stills
Use <Still> for single-frame images. It does not require durationInFrames or fps.
import { Still } from "remotion";
import { Thumbnail } from "./Thumbnail";
export const RemotionRoot = () => {
return (
<Still id="Thumbnail" component={Thumbnail} width={1280} height={720} />
);
};Calculate Metadata
Use calculateMetadata to make dimensions, duration, or props dynamic based on data.
import { Composition, CalculateMetadataFunction } from "remotion";
import { MyComposition, MyCompositionProps } from "./MyComposition";
const calculateMetadata: CalculateMetadataFunction<
MyCompositionProps
> = async ({ props, abortSignal }) => {
const data = await fetch(`https://api.example.com/video/${props.videoId}`, {
signal: abortSignal,
}).then((res) => res.json());
return {
durationInFrames: Math.ceil(data.duration * 30),
props: {
...props,
videoUrl: data.url,
},
};
};
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100} // Placeholder, will be overridden
fps={30}
width={1080}
height={1080}
defaultProps={{ videoId: "abc123" }}
calculateMetadata={calculateMetadata}
/>
);
};The function can return props, durationInFrames, width, height, fps, and codec-related defaults. It runs once before rendering begins.
Nesting compositions within another
To add a composition within another composition, you can use the <Sequence> component with a width and height prop to specify the size of the composition.
<AbsoluteFill>
<Sequence width={COMPOSITION_WIDTH} height={COMPOSITION_HEIGHT}>
<CompositionComponent />
</Sequence>
</AbsoluteFill>Displaying captions in Remotion
This guide explains how to display captions in Remotion, assuming you already have captions in the `Caption` format.
Prerequisites
Read Transcribing audio for how to generate captions.
First, the `@remotion/captions` package needs to be installed. If it is not installed, use the following command:
npx remotion add @remotion/captionsFetching captions
First, fetch your captions JSON file. Use `useDelayRender()` to hold the render until the captions are loaded:
import { useState, useEffect, useCallback } from "react";
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
import type { Caption } from "@remotion/captions";
export const MyComponent: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender());
const fetchCaptions = useCallback(async () => {
try {
// Assuming captions.json is in the public/ folder.
const response = await fetch(staticFile("captions123.json"));
const data = await response.json();
setCaptions(data);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
if (!captions) {
return null;
}
return <AbsoluteFill>{/* Render captions here */}</AbsoluteFill>;
};Creating pages
Use createTikTokStyleCaptions() to group captions into pages. The combineTokensWithinMilliseconds option controls how many words appear at once:
import { useMemo } from "react";
import { createTikTokStyleCaptions } from "@remotion/captions";
import type { Caption } from "@remotion/captions";
// How often captions should switch (in milliseconds)
// Higher values = more words per page
// Lower values = fewer words (more word-by-word)
const SWITCH_CAPTIONS_EVERY_MS = 1200;
const { pages } = useMemo(() => {
return createTikTokStyleCaptions({
captions,
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
});
}, [captions]);Rendering with Sequences
Map over the pages and render each one in a <Sequence>. Calculate the start frame and duration from the page timing:
import { Sequence, useVideoConfig, AbsoluteFill } from "remotion";
import type { TikTokPage } from "@remotion/captions";
const CaptionedContent: React.FC = () => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill>
{pages.map((page, index) => {
const nextPage = pages[index + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = Math.min(
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps,
);
const durationInFrames = endFrame - startFrame;
if (durationInFrames <= 0) {
return null;
}
return (
<Sequence
key={index}
from={startFrame}
durationInFrames={durationInFrames}
>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};White-space preservation
The captions are whitespace sensitive. You should include spaces in the text field before each word. Use whiteSpace: "pre" to preserve the whitespace in the captions.
Separate component for captions
Put captioning logic in a separate component. Make a new file for it.
Word highlighting
A caption page contains tokens which you can use to highlight the currently spoken word:
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
import type { TikTokPage } from "@remotion/captions";
const HIGHLIGHT_COLOR = "#39E508";
const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Current time relative to the start of the sequence
const currentTimeMs = (frame / fps) * 1000;
// Convert to absolute time by adding the page start
const absoluteTimeMs = page.startMs + currentTimeMs;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ fontSize: 80, fontWeight: "bold", whiteSpace: "pre" }}>
{page.tokens.map((token) => {
const isActive =
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
return (
<span
key={token.fromMs}
style={{ color: isActive ? HIGHLIGHT_COLOR : "white" }}
>
{token.text}
</span>
);
})}
</div>
</AbsoluteFill>
);
};Display captions alongside video content
By default, put the captions alongside the video content, so the captions are in sync. For each video, make a new captions JSON file.
<AbsoluteFill>
<Video src={staticFile("video.mp4")} />
<CaptionPage page={page} />
</AbsoluteFill>Extracting frames from videos
Use Mediabunny to extract frames from videos at specific timestamps. This is useful for generating thumbnails, filmstrips, or processing individual frames.
The extractFrames() function
This function can be copy-pasted into any project.
import {
ALL_FORMATS,
Input,
UrlSource,
VideoSample,
VideoSampleSink,
} from "mediabunny";
type Options = {
track: { width: number; height: number };
container: string;
durationInSeconds: number | null;
};
export type ExtractFramesTimestampsInSecondsFn = (
options: Options,
) => Promise<number[]> | number[];
export type ExtractFramesProps = {
src: string;
timestampsInSeconds: number[] | ExtractFramesTimestampsInSecondsFn;
onVideoSample: (sample: VideoSample) => void;
signal?: AbortSignal;
};
export async function extractFrames({
src,
timestampsInSeconds,
onVideoSample,
signal,
}: ExtractFramesProps): Promise<void> {
using input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src),
});
const [durationInSeconds, format, videoTrack] = await Promise.all([
input.computeDuration(),
input.getFormat(),
input.getPrimaryVideoTrack(),
]);
if (!videoTrack) {
throw new Error("No video track found in the input");
}
if (signal?.aborted) {
throw new Error("Aborted");
}
const timestamps =
typeof timestampsInSeconds === "function"
? await timestampsInSeconds({
track: {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
},
container: format.name,
durationInSeconds,
})
: timestampsInSeconds;
if (timestamps.length === 0) {
return;
}
if (signal?.aborted) {
throw new Error("Aborted");
}
const sink = new VideoSampleSink(videoTrack);
for await (using videoSample of sink.samplesAtTimestamps(timestamps)) {
if (signal?.aborted) {
break;
}
if (!videoSample) {
continue;
}
onVideoSample(videoSample);
}
}Basic usage
Extract frames at specific timestamps:
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
const canvas = document.createElement("canvas");
canvas.width = sample.displayWidth;
canvas.height = sample.displayHeight;
const ctx = canvas.getContext("2d");
sample.draw(ctx!, 0, 0);
},
});Creating a filmstrip
Use a callback function to dynamically calculate timestamps based on video metadata:
const canvasWidth = 500;
const canvasHeight = 80;
const fromSeconds = 0;
const toSeconds = 10;
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: async ({ track, durationInSeconds }) => {
const aspectRatio = track.width / track.height;
const amountOfFramesFit = Math.ceil(
canvasWidth / (canvasHeight * aspectRatio),
);
const segmentDuration = toSeconds - fromSeconds;
const timestamps: number[] = [];
for (let i = 0; i < amountOfFramesFit; i++) {
timestamps.push(
fromSeconds + (segmentDuration / amountOfFramesFit) * (i + 0.5),
);
}
return timestamps;
},
onVideoSample: (sample) => {
console.log(`Frame at ${sample.timestamp}s`);
const canvas = document.createElement("canvas");
canvas.width = sample.displayWidth;
canvas.height = sample.displayHeight;
const ctx = canvas.getContext("2d");
sample.draw(ctx!, 0, 0);
},
});Cancellation with AbortSignal
Cancel frame extraction after a timeout:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
using frame = sample;
const canvas = document.createElement("canvas");
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
const ctx = canvas.getContext("2d");
frame.draw(ctx!, 0, 0);
},
signal: controller.signal,
});
console.log("Frame extraction complete!");
} catch (error) {
console.error("Frame extraction was aborted or failed:", error);
}Timeout with Promise.race
const controller = new AbortController();
const timeoutPromise = new Promise<never>((_, reject) => {
const timeoutId = setTimeout(() => {
controller.abort();
reject(new Error("Frame extraction timed out after 10 seconds"));
}, 10000);
controller.signal.addEventListener("abort", () => clearTimeout(timeoutId), {
once: true,
});
});
try {
await Promise.race([
extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
using frame = sample;
const canvas = document.createElement("canvas");
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
const ctx = canvas.getContext("2d");
frame.draw(ctx!, 0, 0);
},
signal: controller.signal,
}),
timeoutPromise,
]);
console.log("Frame extraction complete!");
} catch (error) {
console.error("Frame extraction was aborted or failed:", error);
}FFmpeg in Remotion
ffmpeg and ffprobe do not need to be installed. They are available via the bunx remotion ffmpeg and bunx remotion ffprobe:
bunx remotion ffmpeg -i input.mp4 output.mp3
bunx remotion ffprobe input.mp4Trimming videos
You have 2 options for trimming videos:
1. Use the FFMpeg command line. You MUST re-encode the video to avoid frozen frames at the start of the video.
# Re-encodes from the exact frame
bunx remotion ffmpeg -ss 00:00:05 -i public/input.mp4 -to 00:00:10 -c:v libx264 -c:a aac public/output.mp42. Use the trimBefore and trimAfter props of the <Video> component. The benefit is that this is non-destructive and you can change the trim at any time.
import { Video } from "@remotion/media";
<Video
src={staticFile("video.mp4")}
trimBefore={5 * fps}
trimAfter={10 * fps}
/>;Using fonts in Remotion
Google Fonts with @remotion/google-fonts
The recommended way to use Google Fonts. It's type-safe and automatically blocks rendering until the font is ready.
Prerequisites
First, the @remotion/google-fonts package needs to be installed. If it is not installed, use the following command:
npx remotion add @remotion/google-fonts # If project uses npm
bunx remotion add @remotion/google-fonts # If project uses bun
yarn remotion add @remotion/google-fonts # If project uses yarn
pnpm exec remotion add @remotion/google-fonts # If project uses pnpmimport { loadFont } from "@remotion/google-fonts/Lobster";
const { fontFamily } = loadFont();
export const MyComposition = () => {
return <div style={{ fontFamily }}>Hello World</div>;
};Preferrably, specify only needed weights and subsets to reduce file size:
import { loadFont } from "@remotion/google-fonts/Roboto";
const { fontFamily } = loadFont("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});Waiting for font to load
Use waitUntilDone() if you need to know when the font is ready:
import { loadFont } from "@remotion/google-fonts/Lobster";
const { fontFamily, waitUntilDone } = loadFont();
await waitUntilDone();Local fonts with @remotion/fonts
For local font files, use the @remotion/fonts package.
Prerequisites
First, install @remotion/fonts:
npx remotion add @remotion/fonts # If project uses npm
bunx remotion add @remotion/fonts # If project uses bun
yarn remotion add @remotion/fonts # If project uses yarn
pnpm exec remotion add @remotion/fonts # If project uses pnpmLoading a local font
Place your font file in the public/ folder and use loadFont():
import { loadFont } from "@remotion/fonts";
import { staticFile } from "remotion";
await loadFont({
family: "MyFont",
url: staticFile("MyFont-Regular.woff2"),
});
export const MyComposition = () => {
return <div style={{ fontFamily: "MyFont" }}>Hello World</div>;
};Loading multiple weights
Load each weight separately with the same family name:
import { loadFont } from "@remotion/fonts";
import { staticFile } from "remotion";
await Promise.all([
loadFont({
family: "Inter",
url: staticFile("Inter-Regular.woff2"),
weight: "400",
}),
loadFont({
family: "Inter",
url: staticFile("Inter-Bold.woff2"),
weight: "700",
}),
]);Available options
loadFont({
family: "MyFont", // Required: name to use in CSS
url: staticFile("font.woff2"), // Required: font file URL
format: "woff2", // Optional: auto-detected from extension
weight: "400", // Optional: font weight
style: "normal", // Optional: normal or italic
display: "block", // Optional: font-display behavior
});Using in components
Call loadFont() at the top level of your component or in a separate file that's imported early:
import { loadFont } from "@remotion/google-fonts/Montserrat";
const { fontFamily } = loadFont("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});
export const Title: React.FC<{ text: string }> = ({ text }) => {
return (
<h1
style={{
fontFamily,
fontSize: 80,
fontWeight: "bold",
}}
>
{text}
</h1>
);
};Getting audio duration with Mediabunny
Mediabunny can extract the duration of an audio file. It works in browser, Node.js, and Bun environments.
Getting audio duration
```tsx title="get-audio-duration.ts" import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getAudioDuration = async (src: string) => { const input = new Input({ formats: ALL_FORMATS, source: new UrlSource(src, { getRetryDelay: () => null, }), });
const durationInSeconds = await input.computeDuration(); return durationInSeconds; };
## Usage
const duration = await getAudioDuration("https://remotion.media/audio.mp3"); console.log(duration); // e.g. 180.5 (seconds)
## Using with staticFile in Remotion
Make sure to wrap the file path in `staticFile()`:
import { staticFile } from "remotion";
const duration = await getAudioDuration(staticFile("audio.mp3"));
## In Node.js and Bun
Use `FileSource` instead of `UrlSource`:
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({ formats: ALL_FORMATS, source: new FileSource(file), // File object from input or drag-drop });
Getting video dimensions with Mediabunny
Mediabunny can extract the width and height of a video file. It works in browser, Node.js, and Bun environments.
Getting video dimensions
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getVideoDimensions = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found");
}
return {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
};
};Usage
const dimensions = await getVideoDimensions("https://remotion.media/video.mp4");
console.log(dimensions.width); // e.g. 1920
console.log(dimensions.height); // e.g. 1080Using with local files
For local files, use FileSource instead of UrlSource:
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({
formats: ALL_FORMATS,
source: new FileSource(file), // File object from input or drag-drop
});
const videoTrack = await input.getPrimaryVideoTrack();
const width = videoTrack.displayWidth;
const height = videoTrack.displayHeight;Using with staticFile in Remotion
import { staticFile } from "remotion";
const dimensions = await getVideoDimensions(staticFile("video.mp4"));Getting video duration with Mediabunny
Mediabunny can extract the duration of a video file. It works in browser, Node.js, and Bun environments.
Getting video duration
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getVideoDuration = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
const durationInSeconds = await input.computeDuration();
return durationInSeconds;
};Usage
const duration = await getVideoDuration("https://remotion.media/video.mp4");
console.log(duration); // e.g. 10.5 (seconds)Video files from the public/ directory
Make sure to wrap the file path in staticFile():
import { staticFile } from "remotion";
const duration = await getVideoDuration(staticFile("video.mp4"));In Node.js and Bun
Use FileSource instead of UrlSource:
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({
formats: ALL_FORMATS,
source: new FileSource(file), // File object from input or drag-drop
});
const durationInSeconds = await input.computeDuration();Using Animated images in Remotion
Basic usage
Use <AnimatedImage> to display a GIF, APNG, AVIF or WebP image synchronized with Remotion's timeline:
import { AnimatedImage, staticFile } from "remotion";
export const MyComposition = () => {
return (
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} />
);
};Remote URLs are also supported (must have CORS enabled):
<AnimatedImage
src="https://example.com/animation.gif"
width={500}
height={500}
/>Sizing and fit
Control how the image fills its container with the fit prop:
// Stretch to fill (default)
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="fill" />
// Maintain aspect ratio, fit inside container
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="contain" />
// Fill container, crop if needed
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="cover" />Playback speed
Use playbackRate to control the animation speed:
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={2} /> {/* 2x speed */}
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={0.5} /> {/* Half speed */}Looping behavior
Control what happens when the animation finishes:
// Loop indefinitely (default)
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="loop" />
// Play once, show final frame
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="pause-after-finish" />
// Play once, then clear canvas
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="clear-after-finish" />Styling
Use the style prop for additional CSS (use width and height props for sizing):
<AnimatedImage
src={staticFile("animation.gif")}
width={500}
height={500}
style={{
borderRadius: 20,
position: "absolute",
top: 100,
left: 50,
}}
/>Getting GIF duration
Use getGifDurationInSeconds() from @remotion/gif to get the duration of a GIF.
npx remotion add @remotion/gifimport { getGifDurationInSeconds } from "@remotion/gif";
import { staticFile } from "remotion";
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
console.log(duration); // e.g. 2.5This is useful for setting the composition duration to match the GIF:
import { getGifDurationInSeconds } from "@remotion/gif";
import { staticFile, CalculateMetadataFunction } from "remotion";
const calculateMetadata: CalculateMetadataFunction = async () => {
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
return {
durationInFrames: Math.ceil(duration * 30),
};
};Alternative
If <AnimatedImage> does not work (only supported in Chrome and Firefox), you can use <Gif> from @remotion/gif instead.
npx remotion add @remotion/gif # If project uses npm
bunx remotion add @remotion/gif # If project uses bun
yarn remotion add @remotion/gif # If project uses yarn
pnpm exec remotion add @remotion/gif # If project uses pnpmimport { Gif } from "@remotion/gif";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Gif src={staticFile("animation.gif")} width={500} height={500} />;
};The <Gif> component has the same props as <AnimatedImage> but only supports GIF files.
Using images in Remotion
The <Img> component
Always use the <Img> component from remotion to display images:
import { Img, staticFile } from "remotion";
export const MyComposition = () => {
return <Img src={staticFile("photo.png")} />;
};Important restrictions
You MUST use the `<Img>` component from `remotion`. Do not use:
- Native HTML
<img>elements - Next.js
<Image>component - CSS
background-image
The <Img> component ensures images are fully loaded before rendering, preventing flickering and blank frames during video export.
Local images with staticFile()
Place images in the public/ folder and use staticFile() to reference them:
my-video/
├─ public/
│ ├─ logo.png
│ ├─ avatar.jpg
│ └─ icon.svg
├─ src/
├─ package.jsonimport { Img, staticFile } from "remotion";
<Img src={staticFile("logo.png")} />;Remote images
Remote URLs can be used directly without staticFile():
<Img src="https://example.com/image.png" />Ensure remote images have CORS enabled.
For animated GIFs, use the <Gif> component from @remotion/gif instead.
Sizing and positioning
Use the style prop to control size and position:
<Img
src={staticFile("photo.png")}
style={{
width: 500,
height: 300,
position: "absolute",
top: 100,
left: 50,
objectFit: "cover",
}}
/>Dynamic image paths
Use template literals for dynamic file references:
import { Img, staticFile, useCurrentFrame } from "remotion";
const frame = useCurrentFrame();
// Image sequence
<Img src={staticFile(`frames/frame${frame}.png`)} />
// Selecting based on props
<Img src={staticFile(`avatars/${props.userId}.png`)} />
// Conditional images
<Img src={staticFile(`icons/${isActive ? "active" : "inactive"}.svg`)} />This pattern is useful for:
- Image sequences (frame-by-frame animations)
- User-specific avatars or profile images
- Theme-based icons
- State-dependent graphics
Getting image dimensions
Use getImageDimensions() to get the dimensions of an image:
import { getImageDimensions, staticFile } from "remotion";
const { width, height } = await getImageDimensions(staticFile("photo.png"));This is useful for calculating aspect ratios or sizing compositions:
import {
getImageDimensions,
staticFile,
CalculateMetadataFunction,
} from "remotion";
const calculateMetadata: CalculateMetadataFunction = async () => {
const { width, height } = await getImageDimensions(staticFile("photo.png"));
return {
width,
height,
};
};Importing .srt subtitles into Remotion
If you have an existing .srt subtitle file, you can import it into Remotion using parseSrt() from @remotion/captions.
If you don't have a .srt file, read Transcribing audio for how to generate captions instead.
Prerequisites
First, the @remotion/captions package needs to be installed. If it is not installed, use the following command:
npx remotion add @remotion/captions # If project uses npm
bunx remotion add @remotion/captions # If project uses bun
yarn remotion add @remotion/captions # If project uses yarn
pnpm exec remotion add @remotion/captions # If project uses pnpmReading an .srt file
Use staticFile() to reference an .srt file in your public folder, then fetch and parse it:
import { useState, useEffect, useCallback } from "react";
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
import { parseSrt } from "@remotion/captions";
import type { Caption } from "@remotion/captions";
export const MyComponent: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender());
const fetchCaptions = useCallback(async () => {
try {
const response = await fetch(staticFile("subtitles.srt"));
const text = await response.text();
const { captions: parsed } = parseSrt({ input: text });
setCaptions(parsed);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
if (!captions) {
return null;
}
return <AbsoluteFill>{/* Use captions here */}</AbsoluteFill>;
};Remote URLs are also supported - you can fetch() a remote file via URL instead of using staticFile().
Using imported captions
Once parsed, the captions are in the Caption format and can be used with all @remotion/captions utilities.
Light Leaks
This only works from Remotion 4.0.415 and up. Use npx remotion versions to check your Remotion version and npx remotion upgrade to upgrade your Remotion version.
<LightLeak> from @remotion/light-leaks renders a WebGL-based light leak effect. It reveals during the first half of its duration and retracts during the second half.
Typically used inside a <TransitionSeries.Overlay> to play over the cut point between two scenes. See the transitions rule for <TransitionSeries> and overlay usage.
Prerequisites
npx remotion add @remotion/light-leaksBasic usage with TransitionSeries
import { TransitionSeries } from "@remotion/transitions";
import { LightLeak } from "@remotion/light-leaks";
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneA />
</TransitionSeries.Sequence>
<TransitionSeries.Overlay durationInFrames={30}>
<LightLeak />
</TransitionSeries.Overlay>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneB />
</TransitionSeries.Sequence>
</TransitionSeries>;Props
durationInFrames?— defaults to the parent sequence/composition duration. The effect reveals during the first half and retracts during the second half.seed?— determines the shape of the light leak pattern. Different seeds produce different patterns. Default:0.hueShift?— rotates the hue in degrees (0–360). Default:0(yellow-to-orange).120= green,240= blue.
Customizing the look
import { LightLeak } from "@remotion/light-leaks";
// Blue-tinted light leak with a different pattern
<LightLeak seed={5} hueShift={240} />;
// Green-tinted light leak
<LightLeak seed={2} hueShift={120} />;Standalone usage
<LightLeak> can also be used outside of <TransitionSeries>, for example as a decorative overlay in any composition:
import { AbsoluteFill } from "remotion";
import { LightLeak } from "@remotion/light-leaks";
const MyComp: React.FC = () => (
<AbsoluteFill>
<MyContent />
<LightLeak durationInFrames={60} seed={3} />
</AbsoluteFill>
);Using Lottie Animations in Remotion
Prerequisites
First, the @remotion/lottie package needs to be installed. If it is not, use the following command:
npx remotion add @remotion/lottie # If project uses npm
bunx remotion add @remotion/lottie # If project uses bun
yarn remotion add @remotion/lottie # If project uses yarn
pnpm exec remotion add @remotion/lottie # If project uses pnpmDisplaying a Lottie file
To import a Lottie animation:
- Fetch the Lottie asset
- Wrap the loading process in
delayRender()andcontinueRender() - Save the animation data in a state
- Render the Lottie animation using the
Lottiecomponent from the@remotion/lottiepackage
import { Lottie, LottieAnimationData } from "@remotion/lottie";
import { useEffect, useState } from "react";
import { cancelRender, continueRender, delayRender } from "remotion";
export const MyAnimation = () => {
const [handle] = useState(() => delayRender("Loading Lottie animation"));
const [animationData, setAnimationData] =
useState<LottieAnimationData | null>(null);
useEffect(() => {
fetch("https://assets4.lottiefiles.com/packages/lf20_zyquagfl.json")
.then((data) => data.json())
.then((json) => {
setAnimationData(json);
continueRender(handle);
})
.catch((err) => {
cancelRender(err);
});
}, [handle]);
if (!animationData) {
return null;
}
return <Lottie animationData={animationData} />;
};Styling and animating
Lottie supports the style prop to allow styles and animations:
return (
<Lottie animationData={animationData} style={{ width: 400, height: 400 }} />
);Maps can be added to a Remotion video with Mapbox. The Mapbox documentation has the API reference.
Prerequisites
Mapbox and @turf/turf need to be installed.
Search the project for lockfiles and run the correct command depending on the package manager:
If package-lock.json is found, use the following command:
npm i mapbox-gl @turf/turf @types/mapbox-glIf bun.lock is found, use the following command:
bun i mapbox-gl @turf/turf @types/mapbox-glIf yarn.lock is found, use the following command:
yarn add mapbox-gl @turf/turf @types/mapbox-glIf pnpm-lock.yaml is found, use the following command:
pnpm i mapbox-gl @turf/turf @types/mapbox-glThe user needs to create a free Mapbox account and create an access token by visiting https://console.mapbox.com/account/access-tokens/.
The mapbox token needs to be added to the .env file:
```txt title=".env" REMOTION_MAPBOX_TOKEN==pk.your-mapbox-access-token
## Adding a map
Here is a basic example of a map in Remotion.
import { useEffect, useMemo, useRef, useState } from "react"; import { AbsoluteFill, useDelayRender, useVideoConfig } from "remotion"; import mapboxgl, { Map } from "mapbox-gl";
export const lineCoordinates = [ [6.56158447265625, 46.059891147620725], [6.5691375732421875, 46.05679376154153], [6.5842437744140625, 46.05059898938315], [6.594886779785156, 46.04702502069337], [6.601066589355469, 46.0460718554722], [6.6089630126953125, 46.0365370783104], [6.6185760498046875, 46.018420689207964], ];
mapboxgl.accessToken = process.env.REMOTION_MAPBOX_TOKEN as string;
export const MyComposition = () => { const ref = useRef<HTMLDivElement>(null); const { delayRender, continueRender } = useDelayRender();
const { width, height } = useVideoConfig(); const [handle] = useState(() => delayRender("Loading map...")); const [map, setMap] = useState<Map | null>(null);
useEffect(() => { const _map = new Map({ container: ref.current!, zoom: 11.53, center: [6.5615, 46.0598], pitch: 65, bearing: 0, style: "mapbox://styles/mapbox/standard", interactive: false, fadeDuration: 0, });
_map.on("style.load", () => { // Hide all features from the Mapbox Standard style const hideFeatures = [ "showRoadsAndTransit", "showRoads", "showTransit", "showPedestrianRoads", "showRoadLabels", "showTransitLabels", "showPlaceLabels", "showPointOfInterestLabels", "showPointsOfInterest", "showAdminBoundaries", "showLandmarkIcons", "showLandmarkIconLabels", "show3dObjects", "show3dBuildings", "show3dTrees", "show3dLandmarks", "show3dFacades", ]; for (const feature of hideFeatures) { _map.setConfigProperty("basemap", feature, false); }
_map.setConfigProperty("basemap", "colorTrunks", "rgba(0, 0, 0, 0)");
_map.addSource("trace", { type: "geojson", data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: lineCoordinates, }, }, }); _map.addLayer({ type: "line", source: "trace", id: "line", paint: { "line-color": "black", "line-width": 5, }, layout: { "line-cap": "round", "line-join": "round", }, }); });
_map.on("load", () => { continueRender(handle); setMap(_map); }); }, [handle, lineCoordinates]);
const style: React.CSSProperties = useMemo( () => ({ width, height, position: "absolute" }), [width, height], );
return <AbsoluteFill ref={ref} style={style} />; };
The following is important in Remotion:
- Animations must be driven by `useCurrentFrame()` and animations that Mapbox brings itself should be disabled. For example, the `fadeDuration` prop should be set to `0`, `interactive` should be set to `false`, etc.
- Loading the map should be delayed using `useDelayRender()` and the map should be set to `null` until it is loaded.
- The element containing the ref MUST have an explicit width and height and `position: "absolute"`.
- Do not add a `_map.remove();` cleanup function.
## Drawing lines
Unless I request it, do not add a glow effect to the lines.
Unless I request it, do not add additional points to the lines.
## Map style
By default, use the `mapbox://styles/mapbox/standard` style.
Hide the labels from the base map style.
Unless I request otherwise, remove all features from the Mapbox Standard style.
// Hide all features from the Mapbox Standard style const hideFeatures = [ "showRoadsAndTransit", "showRoads", "showTransit", "showPedestrianRoads", "showRoadLabels", "showTransitLabels", "showPlaceLabels", "showPointOfInterestLabels", "showPointsOfInterest", "showAdminBoundaries", "showLandmarkIcons", "showLandmarkIconLabels", "show3dObjects", "show3dBuildings", "show3dTrees", "show3dLandmarks", "show3dFacades", ]; for (const feature of hideFeatures) { _map.setConfigProperty("basemap", feature, false); }
_map.setConfigProperty("basemap", "colorMotorways", "transparent"); _map.setConfigProperty("basemap", "colorRoads", "transparent"); _map.setConfigProperty("basemap", "colorTrunks", "transparent");
## Animating the camera
You can animate the camera along the line by adding a `useEffect` hook that updates the camera position based on the current frame.
Unless I ask for it, do not jump between camera angles.
import * as turf from "@turf/turf"; import { interpolate } from "remotion"; import { Easing } from "remotion"; import { useCurrentFrame, useVideoConfig, useDelayRender } from "remotion";
const animationDuration = 20; const cameraAltitude = 4000;
const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const { delayRender, continueRender } = useDelayRender();
useEffect(() => { if (!map) { return; } const handle = delayRender("Moving point...");
const routeDistance = turf.length(turf.lineString(lineCoordinates));
const progress = interpolate( frame / fps, [0.00001, animationDuration], [0, 1], { easing: Easing.inOut(Easing.sin), extrapolateLeft: "clamp", extrapolateRight: "clamp", }, );
const camera = map.getFreeCameraOptions();
const alongRoute = turf.along( turf.lineString(lineCoordinates), routeDistance * progress, ).geometry.coordinates;
camera.lookAtPoint({ lng: alongRoute[0], lat: alongRoute[1], });
map.setFreeCameraOptions(camera); map.once("idle", () => continueRender(handle)); }, [lineCoordinates, fps, frame, handle, map]);
Notes:
IMPORTANT: Keep the camera by default so north is up.
IMPORTANT: For multi-step animations, set all properties at all stages (zoom, position, line progress) to prevent jumps. Override initial values.
- The progress is clamped to a minimum value to avoid the line being empty, which can lead to turf errors
- See [Timing](./timing.md) for more options for timing.
- Consider the dimensions of the composition and make the lines thick enough and the label font size large enough to be legible for when the composition is scaled down.
## Animating lines
### Straight lines (linear interpolation)
To animate a line that appears straight on the map, use linear interpolation between coordinates. Do NOT use turf's `lineSliceAlong` or `along` functions, as they use geodesic (great circle) calculations which appear curved on a Mercator projection.
const frame = useCurrentFrame(); const { durationInFrames } = useVideoConfig();
useEffect(() => { if (!map) return;
const animationHandle = delayRender("Animating line...");
const progress = interpolate(frame, [0, durationInFrames - 1], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic), });
// Linear interpolation for a straight line on the map const start = lineCoordinates[0]; const end = lineCoordinates[1]; const currentLng = start[0] + (end[0] - start[0]) progress; const currentLat = start[1] + (end[1] - start[1]) progress;
const lineData: GeoJSON.Feature<GeoJSON.LineString> = { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: [start, [currentLng, currentLat]], }, };
const source = map.getSource("trace") as mapboxgl.GeoJSONSource; if (source) { source.setData(lineData); }
map.once("idle", () => continueRender(animationHandle)); }, [frame, map, durationInFrames]);
### Curved lines (geodesic/great circle)
To animate a line that follows the geodesic (great circle) path between two points, use turf's `lineSliceAlong`. This is useful for showing flight paths or the actual shortest distance on Earth.
import * as turf from "@turf/turf";
const routeLine = turf.lineString(lineCoordinates); const routeDistance = turf.length(routeLine);
const currentDistance = Math.max(0.001, routeDistance * progress); const slicedLine = turf.lineSliceAlong(routeLine, 0, currentDistance);
const source = map.getSource("route") as mapboxgl.GeoJSONSource; if (source) { source.setData(slicedLine); }
## Markers
Add labels, and markers where appropriate.
_map.addSource("markers", { type: "geojson", data: { type: "FeatureCollection", features: [ { type: "Feature", properties: { name: "Point 1" }, geometry: { type: "Point", coordinates: [-118.2437, 34.0522] }, }, ], }, });
_map.addLayer({ id: "city-markers", type: "circle", source: "markers", paint: { "circle-radius": 40, "circle-color": "#FF4444", "circle-stroke-width": 4, "circle-stroke-color": "#FFFFFF", }, });
_map.addLayer({ id: "labels", type: "symbol", source: "markers", layout: { "text-field": ["get", "name"], "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"], "text-size": 50, "text-offset": [0, 0.5], "text-anchor": "top", }, paint: { "text-color": "#FFFFFF", "text-halo-color": "#000000", "text-halo-width": 2, }, });
Make sure they are big enough. Check the composition dimensions and scale the labels accordingly.
For a composition size of 1920x1080, the label font size should be at least 40px.
IMPORTANT: Keep the `text-offset` small enough so it is close to the marker. Consider the marker circle radius. For a circle radius of 40, this is a good offset:
"text-offset": [0, 0.5],
## 3D buildings
To enable 3D buildings, use the following code:
_map.setConfigProperty("basemap", "show3dObjects", true); _map.setConfigProperty("basemap", "show3dLandmarks", true); _map.setConfigProperty("basemap", "show3dBuildings", true);
## Rendering
When rendering a map animation, make sure to render with the following flags:
npx remotion render --gl=angle --concurrency=1
Measuring DOM nodes in Remotion
Remotion applies a scale() transform to the video container, which affects values from getBoundingClientRect(). Use useCurrentScale() to get correct measurements.
Measuring element dimensions
import { useCurrentScale } from "remotion";
import { useRef, useEffect, useState } from "react";
export const MyComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const scale = useCurrentScale();
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
setDimensions({
width: rect.width / scale,
height: rect.height / scale,
});
}, [scale]);
return <div ref={ref}>Content to measure</div>;
};Measuring text in Remotion
Prerequisites
Install @remotion/layout-utils if it is not already installed:
npx remotion add @remotion/layout-utilsMeasuring text dimensions
Use measureText() to calculate the width and height of text:
import { measureText } from "@remotion/layout-utils";
const { width, height } = measureText({
text: "Hello World",
fontFamily: "Arial",
fontSize: 32,
fontWeight: "bold",
});Results are cached - duplicate calls return the cached result.
Fitting text to a width
Use fitText() to find the optimal font size for a container:
import { fitText } from "@remotion/layout-utils";
const { fontSize } = fitText({
text: "Hello World",
withinWidth: 600,
fontFamily: "Inter",
fontWeight: "bold",
});
return (
<div
style={{
fontSize: Math.min(fontSize, 80), // Cap at 80px
fontFamily: "Inter",
fontWeight: "bold",
}}
>
Hello World
</div>
);Checking text overflow
Use fillTextBox() to check if text exceeds a box:
import { fillTextBox } from "@remotion/layout-utils";
const box = fillTextBox({ maxBoxWidth: 400, maxLines: 3 });
const words = ["Hello", "World", "This", "is", "a", "test"];
for (const word of words) {
const { exceedsBox } = box.add({
text: word + " ",
fontFamily: "Arial",
fontSize: 24,
});
if (exceedsBox) {
// Text would overflow, handle accordingly
break;
}
}Best practices
Load fonts first: Only call measurement functions after fonts are loaded.
import { loadFont } from "@remotion/google-fonts/Inter";
const { fontFamily, waitUntilDone } = loadFont("normal", {
weights: ["400"],
subsets: ["latin"],
});
waitUntilDone().then(() => {
// Now safe to measure
const { width } = measureText({
text: "Hello",
fontFamily,
fontSize: 32,
});
});Use validateFontIsLoaded: Catch font loading issues early:
measureText({
text: "Hello",
fontFamily: "MyCustomFont",
fontSize: 32,
validateFontIsLoaded: true, // Throws if font not loaded
});Match font properties: Use the same properties for measurement and rendering:
const fontStyle = {
fontFamily: "Inter",
fontSize: 32,
fontWeight: "bold" as const,
letterSpacing: "0.5px",
};
const { width } = measureText({
text: "Hello",
...fontStyle,
});
return <div style={fontStyle}>Hello</div>;Avoid padding and border: Use outline instead of border to prevent layout differences:
<div style={{ outline: "2px solid red" }}>Text</div>To include a sound effect, use the <Audio> tag:
import { Audio } from "@remotion/sfx";
<Audio src={"https://remotion.media/whoosh.wav"} />;The following sound effects are available:
https://remotion.media/whoosh.wavhttps://remotion.media/whip.wavhttps://remotion.media/page-turn.wavhttps://remotion.media/switch.wavhttps://remotion.media/mouse-click.wavhttps://remotion.media/shutter-modern.wavhttps://remotion.media/shutter-old.wav
For more sound effects, search the internet. A good resource is https://github.com/kapishdima/soundcn/tree/main/assets.
All captions must be processed in JSON. The captions must use the Caption type which is the following:
import type { Caption } from "@remotion/captions";This is the definition:
type Caption = {
text: string;
startMs: number;
endMs: number;
timestampMs: number | null;
confidence: number | null;
};Generating captions
To transcribe video and audio files to generate captions, load the ./transcribe-captions.md file for more instructions.
Displaying captions
To display captions in your video, load the ./display-captions.md file for more instructions.
Importing captions
To import captions from a .srt file, load the ./import-srt-captions.md file for more instructions.
{
"useTabs": false,
"bracketSpacing": true,
"tabWidth": 2
}
import { config } from "@remotion/eslint-config-flat";
export default config;
import { Config } from "@remotion/cli/config";
import { enableTailwind } from "@remotion/tailwind-v4";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
Config.overrideWebpackConfig(enableTailwind);