DOMAIN:VISUAL_PRODUCTION:REMOTION_PATTERNS¶
OWNER: felice
UPDATED: 2026-03-24
SCOPE: Remotion deep dive — project structure, core concepts, patterns, rendering, CI/CD
AGENTS: felice (primary), floris/floor (frontend integration)
PARENT: Visual Production
REMOTION:PROJECT_SETUP¶
INITIALIZATION¶
TOOL: Remotion (React-based programmatic video framework)
VERSION: 4.x (latest stable)
RUN: npx create-video@latest my-video --template blank
DIRECTORY_STRUCTURE¶
my-video/
├── src/
│ ├── Root.tsx -- registers all compositions
│ ├── compositions/
│ │ ├── MyVideo.tsx -- main video entry component
│ │ ├── Intro.tsx -- intro sequence
│ │ ├── Scene1.tsx -- content scene
│ │ ├── Scene2.tsx -- content scene
│ │ └── Outro.tsx -- outro sequence
│ ├── components/
│ │ ├── AnimatedText.tsx -- reusable animated text
│ │ ├── BarChart.tsx -- data visualization
│ │ ├── FadeIn.tsx -- fade-in wrapper
│ │ ├── ProgressBar.tsx -- progress indicator
│ │ └── Countdown.tsx -- countdown timer
│ ├── lib/
│ │ ├── constants.ts -- fps, dimensions, durations
│ │ ├── springs.ts -- reusable spring configs
│ │ ├── colors.ts -- brand color palette
│ │ └── fonts.ts -- font loading
│ └── assets/
│ ├── fonts/
│ ├── images/
│ └── audio/
├── public/ -- static files (accessed via staticFile())
│ ├── audio/
│ ├── video/
│ └── images/
├── remotion.config.ts -- Remotion configuration
├── package.json
└── tsconfig.json
RULE: separate compositions (full videos) from components (reusable building blocks)
RULE: all duration/dimension constants in lib/constants.ts — never hardcode in components
RULE: all spring configurations in lib/springs.ts — reuse for visual consistency
CONSTANTS_FILE¶
// src/lib/constants.ts
export const FPS = 30;
export const WIDTH = 1920;
export const HEIGHT = 1080;
// Durations in frames
export const INTRO_DURATION = 90; // 3 seconds
export const SCENE_DURATION = 150; // 5 seconds
export const OUTRO_DURATION = 90; // 3 seconds
export const FADE_FRAMES = 15; // 0.5 seconds
// Colors
export const BRAND_PRIMARY = '#2563EB';
export const BRAND_DARK = '#1E293B';
export const BRAND_LIGHT = '#F8FAFC';
REMOTION:CORE_CONCEPTS¶
COMPOSITION¶
Defines a renderable video with metadata. Registered in Root.tsx.
// src/Root.tsx
import { Composition } from 'remotion';
import { MyVideo } from './compositions/MyVideo';
import { FPS, WIDTH, HEIGHT } from './lib/constants';
export const RemotionRoot = () => (
<>
<Composition
id="MyVideo"
component={MyVideo}
durationInFrames={300}
fps={FPS}
width={WIDTH}
height={HEIGHT}
defaultProps={{
title: 'Default Title',
data: [],
}}
/>
<Composition
id="Thumbnail"
component={MyVideo}
durationInFrames={1}
fps={FPS}
width={1280}
height={720}
defaultProps={{
title: 'Thumbnail',
data: [],
}}
/>
</>
);
RULE: register a separate "Thumbnail" composition for poster frame generation
RULE: use defaultProps for data-driven videos (injected at render time)
USE_CURRENT_FRAME¶
Returns the current frame number (0-indexed). This is the fundamental animation primitive.
const frame = useCurrentFrame();
// frame 0 = first frame
// frame 29 = 1 second mark at 30fps
// frame 299 = last frame of a 10-second video
RULE: never use useState, useEffect with timers, setTimeout, or setInterval
RULE: every visual state must be derivable from frame alone — Remotion renders frame-by-frame
USE_VIDEO_CONFIG¶
Returns composition metadata:
RULE: use fps to convert between seconds and frames: seconds * fps = frames
INTERPOLATE¶
Maps a frame range to an output range. The primary animation function.
import { interpolate } from 'remotion';
const frame = useCurrentFrame();
// Fade in over 30 frames (1 second at 30fps)
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
// Slide up by 50px over frames 10-40
const translateY = interpolate(frame, [10, 40], [50, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
// Scale up then hold
const scale = interpolate(frame, [0, 20], [0.5, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
EXTRAPOLATION:
- 'clamp' — value stays at boundary. USE THIS ALWAYS.
- 'extend' — value continues linearly beyond range. AVOID — causes unexpected values.
- 'identity' — returns the input value beyond range. RARELY useful.
RULE: always set extrapolateLeft: 'clamp' AND extrapolateRight: 'clamp'
RULE: forgetting clamp is the #1 Remotion bug — values go negative or > 1
EASING:
import { Easing } from 'remotion';
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0), // ease-out
});
Common easings:
- Easing.linear — constant speed
- Easing.ease — CSS ease equivalent
- Easing.bezier(0.25, 0.1, 0.25, 1.0) — custom cubic bezier
- Easing.inOut(Easing.ease) — symmetric ease
SPRING¶
Physics-based animation — natural motion without manual easing.
import { spring } from 'remotion';
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = spring({
frame,
fps,
config: {
damping: 10, // resistance (higher = less bounce)
stiffness: 100, // force (higher = faster)
mass: 0.5, // weight (higher = slower, more momentum)
},
});
SPRING_PRESETS:
// src/lib/springs.ts
export const SPRING_BOUNCE = { damping: 8, stiffness: 200, mass: 0.5 }; // playful bounce
export const SPRING_SMOOTH = { damping: 15, stiffness: 100, mass: 1 }; // smooth arrival
export const SPRING_SNAPPY = { damping: 20, stiffness: 300, mass: 0.3 }; // quick, minimal overshoot
export const SPRING_GENTLE = { damping: 12, stiffness: 50, mass: 1.5 }; // slow, organic
RULE: use spring() for entrance animations — more natural than linear interpolate
RULE: use interpolate() for progress bars, timers, and linear transitions
DELAYED_SPRING:
const scale = spring({
frame: frame - 30, // delay by 30 frames (1 second)
fps,
config: SPRING_BOUNCE,
});
SEQUENCE¶
Offsets child rendering by N frames. Creates a local frame context.
import { Sequence } from 'remotion';
<>
<Sequence from={0} durationInFrames={90}>
<Intro /> {/* Intro sees frames 0-89 */}
</Sequence>
<Sequence from={90} durationInFrames={150}>
<Scene1 /> {/* Scene1 sees frames 0-149 (local) */}
</Sequence>
<Sequence from={240} durationInFrames={60}>
<Outro /> {/* Outro sees frames 0-59 (local) */}
</Sequence>
</>
RULE: useCurrentFrame() inside a Sequence returns LOCAL frame (reset to 0)
RULE: this means components are reusable — they don't need to know their absolute position
RULE: set durationInFrames to prevent overlap and aid debugging
ABSOLUTE_FILL¶
Full-frame container — positions content to fill the entire video frame.
import { AbsoluteFill } from 'remotion';
<AbsoluteFill style={{ backgroundColor: '#1E293B' }}>
{/* content fills entire frame */}
</AbsoluteFill>
RULE: use AbsoluteFill as the root of every composition and scene
RULE: layer AbsoluteFill components for z-index stacking
AUDIO_AND_VIDEO¶
import { Audio, Video, staticFile } from 'remotion';
// Audio
<Audio src={staticFile("narration.mp3")} volume={0.8} startFrom={0} />
// Dynamic volume (callback receives LOCAL frame)
<Audio
src={staticFile("bg-music.mp3")}
volume={(f) => interpolate(f, [0, 30], [0, 0.15], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})}
/>
// Video embed
<Video src={staticFile("screen-recording.mp4")} style={{ objectFit: 'contain' }} />
RULE: audio files must be MP3 or WAV — other formats may fail during render
RULE: use staticFile() for all assets in /public — never dynamic import()
RULE: background music volume: 0.10-0.20 when narration is active
RULE: use volume callback for fades and ducking — not separate audio processing
REMOTION:COMMON_PATTERNS¶
ANIMATED_TEXT¶
interface AnimatedTextProps {
text: string;
delay?: number;
fontSize?: number;
color?: string;
}
export const AnimatedText = ({
text,
delay = 0,
fontSize = 48,
color = '#FFFFFF',
}: AnimatedTextProps) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame - delay, [0, 15], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const y = spring({
frame: frame - delay,
fps,
config: { damping: 12, stiffness: 100, mass: 0.8 },
});
const translateY = interpolate(y, [0, 1], [30, 0]);
return (
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
fontSize,
color,
fontWeight: 700,
fontFamily: 'Inter, sans-serif',
}}
>
{text}
</div>
);
};
WORD_BY_WORD_REVEAL¶
export const WordByWord = ({ text, startFrame = 0 }: { text: string; startFrame?: number }) => {
const frame = useCurrentFrame();
const words = text.split(' ');
const framesPerWord = 4;
return (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{words.map((word, i) => {
const wordStart = startFrame + i * framesPerWord;
const opacity = interpolate(frame, [wordStart, wordStart + 6], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<span key={i} style={{ opacity, fontSize: 48, fontWeight: 700, color: '#FFF' }}>
{word}
</span>
);
})}
</div>
);
};
DATA_VISUALIZATION¶
ANIMATED_BAR_CHART:
interface BarData {
label: string;
value: number;
color: string;
}
export const BarChart = ({ data, maxValue }: { data: BarData[]; maxValue: number }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, height: 400 }}>
{data.map((d, i) => {
const progress = spring({
frame: frame - i * 5,
fps,
config: { damping: 12, stiffness: 80, mass: 1 },
});
const height = (d.value / maxValue) * 360 * progress;
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div
style={{
width: 60,
height,
background: d.color,
borderRadius: '8px 8px 0 0',
}}
/>
<span style={{ marginTop: 8, fontSize: 14, color: '#94A3B8' }}>{d.label}</span>
</div>
);
})}
</div>
);
};
ANIMATED_COUNTER:
export const Counter = ({ target, duration = 60 }: { target: number; duration?: number }) => {
const frame = useCurrentFrame();
const value = Math.round(
interpolate(frame, [0, duration], [0, target], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
);
return (
<span style={{ fontSize: 72, fontWeight: 800, fontVariantNumeric: 'tabular-nums' }}>
{value.toLocaleString()}
</span>
);
};
SCREEN_RECORDING_OVERLAY¶
export const ScreenRecordingWithCallouts = () => (
<AbsoluteFill>
<Video src={staticFile("screen-recording.mp4")} style={{ objectFit: 'contain' }} />
{/* Cursor highlight */}
<Sequence from={90} durationInFrames={60}>
<CursorHighlight x={400} y={300} />
</Sequence>
{/* Callout annotation */}
<Sequence from={120} durationInFrames={90}>
<div style={{ position: 'absolute', bottom: 60, left: 40, right: 40 }}>
<AnimatedText text="Click the settings icon to configure your workspace" fontSize={32} />
</div>
</Sequence>
{/* Zoom into area */}
<Sequence from={180} durationInFrames={120}>
<ZoomArea x={350} y={250} width={400} height={300} />
</Sequence>
</AbsoluteFill>
);
COUNTDOWN_TIMER¶
export const Countdown = ({ from, fontSize = 120 }: { from: number; fontSize?: number }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const secondsRemaining = Math.ceil(from - frame / fps);
if (secondsRemaining <= 0) return null;
const progress = (frame % fps) / fps;
const scale = interpolate(progress, [0, 0.5, 1], [1, 1.1, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
<span
style={{
fontSize,
fontWeight: 900,
color: '#FFF',
transform: `scale(${scale})`,
fontVariantNumeric: 'tabular-nums',
}}
>
{secondsRemaining}
</span>
</AbsoluteFill>
);
};
PROGRESS_BAR¶
export const ProgressBar = ({
totalFrames,
color = '#2563EB',
height = 6,
}: {
totalFrames: number;
color?: string;
height?: number;
}) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [0, totalFrames], [0, 100], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height,
background: 'rgba(255,255,255,0.2)',
}}
>
<div style={{ width: `${progress}%`, height: '100%', background: color }} />
</div>
);
};
TRANSITIONS¶
CROSSFADE:
export const Crossfade = ({
children,
durationInFrames,
}: {
children: [React.ReactNode, React.ReactNode];
durationInFrames: number;
}) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill>
<AbsoluteFill style={{ opacity: 1 - progress }}>{children[0]}</AbsoluteFill>
<AbsoluteFill style={{ opacity: progress }}>{children[1]}</AbsoluteFill>
</AbsoluteFill>
);
};
SLIDE_TRANSITION:
export const SlideIn = ({
children,
direction = 'left',
}: {
children: React.ReactNode;
direction?: 'left' | 'right' | 'up' | 'down';
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const progress = spring({ frame, fps, config: { damping: 15, stiffness: 100, mass: 1 } });
const transforms = {
left: `translateX(${interpolate(progress, [0, 1], [-width, 0])}px)`,
right: `translateX(${interpolate(progress, [0, 1], [width, 0])}px)`,
up: `translateY(${interpolate(progress, [0, 1], [-height, 0])}px)`,
down: `translateY(${interpolate(progress, [0, 1], [height, 0])}px)`,
};
return (
<AbsoluteFill style={{ transform: transforms[direction] }}>
{children}
</AbsoluteFill>
);
};
REMOTION:AUDIO_INTEGRATION¶
PATTERNS¶
LAYERED_AUDIO:
export const VideoWithAudio = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
// Fade out music at the end
const musicVolume = interpolate(
frame,
[0, 30, durationInFrames - 30, durationInFrames],
[0, 0.15, 0.15, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
return (
<>
{/* Background music - full duration, fades in and out */}
<Audio src={staticFile("audio/bg-music.mp3")} volume={musicVolume} />
{/* Narration segments */}
<Sequence from={30}>
<Audio src={staticFile("audio/narration-intro.mp3")} volume={0.9} />
</Sequence>
<Sequence from={150}>
<Audio src={staticFile("audio/narration-main.mp3")} volume={0.9} />
</Sequence>
{/* Sound effects */}
<Sequence from={60}>
<Audio src={staticFile("audio/whoosh.mp3")} volume={0.5} />
</Sequence>
{/* Visual content */}
<Sequence from={0} durationInFrames={90}>
<Intro />
</Sequence>
<Sequence from={90}>
<MainContent />
</Sequence>
</>
);
};
AUDIO_DUCKING (lower music when narration plays):
const narrationActive = frame >= 30 && frame <= 180;
const musicVolume = narrationActive ? 0.08 : 0.20;
RULE: music 0.10-0.20 without narration, 0.05-0.10 with narration
RULE: audio files in MP3 or WAV only — other formats may fail during render
RULE: normalize all audio to -16 LUFS before importing
REMOTION:RENDERING¶
LOCAL_CLI¶
BASIC:
WITH_OPTIONS:
npx remotion render src/Root.tsx MyVideo out/video.mp4 \
--codec h264 \
--crf 18 \
--image-format jpeg \
--concurrency 4 \
--gl angle
FLAGS:
- --codec: h264 (default, wide compat), h265 (smaller), vp8/vp9 (WebM), prores (Apple)
- --crf: 0-51. Lower = better quality, larger file. 18 = visually lossless for h264.
- --image-format: jpeg (faster) or png (better for graphics/text)
- --concurrency: parallel frame count. Default: CPU / 2. Increase for faster renders.
- --every-nth-frame: render every Nth frame (for quick previews)
- --gl: "angle" | "egl" | "swangle" — GPU backend
- --props: JSON string of props to inject into composition
RULE: always specify --gl angle on headless Linux (GitHub Actions, CI servers)
RULE: CRF 18-23 for web delivery, CRF 15-18 for archival
RULE: use --image-format png when video contains text or sharp edges
REMOTION_LAMBDA¶
Cloud rendering on AWS Lambda — 200+ concurrent workers.
SETUP:
npx remotion lambda policies role # create IAM role
npx remotion lambda policies user # create IAM user
npx remotion lambda sites create src/Root.tsx --site-name my-video
npx remotion lambda functions deploy
RENDER:
npx remotion lambda render \
--function-name remotion-render-func \
--composition MyVideo \
--props '{"title": "Custom Title"}'
PERFORMANCE:
- 5-minute video renders in ~30 seconds
- cost: ~$0.01-0.05 per render
- requires AWS account with Lambda + S3 permissions
RULE: use Lambda for CI/CD — local rendering blocks the pipeline
RULE: use Lambda for long videos (> 30 seconds)
RULE: local rendering is fine for development and short videos
STILL_RENDERING¶
Generate a single frame as an image (for thumbnails, poster frames, social cards):
RULE: register a dedicated Thumbnail composition for poster frames
RULE: social media cards should be rendered as stills, not extracted from video
REMOTION:CI_CD¶
GITHUB_ACTIONS¶
name: Render Video
on:
push:
paths: ['video/**']
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: video/package-lock.json
- run: cd video && npm ci
- run: cd video && npx remotion render src/Root.tsx MyVideo out/video.mp4 --gl angle
- uses: actions/upload-artifact@v4
with:
name: rendered-video
path: video/out/video.mp4
WITH_LAMBDA:
render-lambda:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: cd video && npm ci
- run: cd video && npx remotion lambda render --function-name remotion-render-func --composition MyVideo
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
RULE: --gl angle is required on GitHub Actions / headless Linux
RULE: cache node_modules to speed up CI
RULE: for long videos, use Lambda to avoid CI timeout (default 6 hours, but wasteful)
REMOTION:PERFORMANCE_OPTIMIZATION¶
RENDERING_SPEED¶
FAST_RENDER:
- increase --concurrency (but watch RAM usage — each worker needs ~500MB)
- use --image-format jpeg for photographic content (faster than png)
- reduce durationInFrames where possible (fewer frames = faster)
- simplify React component tree — fewer DOM nodes per frame
SLOW_RENDER_DIAGNOSIS:
- check if fonts are loading on every frame (load once in Root)
- check for expensive computations in render path (memoize with useMemo)
- check for large images being processed every frame (preload and cache)
MEMORY_MANAGEMENT¶
RULE: avoid creating new objects in the render path — reuse references
RULE: large datasets should be imported once at module level, not per-frame
RULE: for data-driven videos, pass data as defaultProps — not fetched during render
FONT_LOADING¶
// src/lib/fonts.ts
import { staticFile } from 'remotion';
const fontFamily = 'Inter';
const fontUrl = staticFile('fonts/Inter-Variable.woff2');
export const loadFont = () => {
const font = new FontFace(fontFamily, `url(${fontUrl})`);
return font.load().then((loaded) => {
document.fonts.add(loaded);
});
};
// In Root.tsx
import { continueRender, delayRender } from 'remotion';
import { loadFont } from './lib/fonts';
const handle = delayRender();
loadFont().then(() => continueRender(handle));
RULE: always use delayRender / continueRender for async operations
RULE: font loading MUST complete before rendering begins — otherwise text renders as fallback
REMOTION:ANTI_PATTERNS¶
ANTI_PATTERN: using setTimeout or setInterval
FIX: use useCurrentFrame() — Remotion renders frame-by-frame, not in real time
ANTI_PATTERN: using CSS transition or animation
FIX: all animation via interpolate() / spring() driven by frame number
ANTI_PATTERN: using useState for animation state
FIX: derive all state from useCurrentFrame() — no stateful animations
ANTI_PATTERN: fetching data during render
FIX: pass data as defaultProps or import statically
ANTI_PATTERN: dynamic import() for assets
FIX: use staticFile() for assets in /public
ANTI_PATTERN: forgetting extrapolateLeft/Right: 'clamp'
FIX: always set both clamp options on every interpolate() call
ANTI_PATTERN: hardcoding frame numbers in components
FIX: use Sequence for timing, constants for durations
CROSS_REFERENCES¶
- Video production pipeline: video-production.md
- Delivery specifications: delivery-specs.md
- Asset optimization: asset-optimization.md