video-studio
Unified video generation studio — Remotion framework (setup, compositions, Player, rendering), Remotion video production (scenes, transitions, OffthreadVideo, multi-scene), content scripting (data-driven, captions, CSV/JSON), animation (spring physics, interpolate, 12 Disney animation principles), and broader motion design patterns. Single entry point for any 'make a video' task.
Best use case
video-studio is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Unified video generation studio — Remotion framework (setup, compositions, Player, rendering), Remotion video production (scenes, transitions, OffthreadVideo, multi-scene), content scripting (data-driven, captions, CSV/JSON), animation (spring physics, interpolate, 12 Disney animation principles), and broader motion design patterns. Single entry point for any 'make a video' task.
Teams using video-studio should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/video-studio/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How video-studio Compares
| Feature / Agent | video-studio | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Unified video generation studio — Remotion framework (setup, compositions, Player, rendering), Remotion video production (scenes, transitions, OffthreadVideo, multi-scene), content scripting (data-driven, captions, CSV/JSON), animation (spring physics, interpolate, 12 Disney animation principles), and broader motion design patterns. Single entry point for any 'make a video' task.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
Related Guides
SKILL.md Source
# video-studio
Unified video generation studio — Remotion framework (setup, compositions, Player, rendering), Remotion video production (scenes, transitions, OffthreadVideo, multi-scene), content scripting (data-driven, captions, CSV/JSON), animation (spring physics, interpolate, 12 Disney animation principles), and broader motion design patterns. Single entry point for any 'make a video' task.
## Absorbs
- `remotion`
- `remotion-video`
- `remotion-content`
- `remotion-animation`
- `motion-design`
---
## From `remotion`
> Remotion framework hub — project setup, composition architecture, rendering pipeline, Player embedding, and deployment patterns
# Remotion — Framework Hub
## Purpose
Hub skill for the Remotion ecosystem. Covers project setup, composition architecture, the rendering pipeline (local, Lambda, Cloud Run), the `<Player>` component for web embedding, and orchestrates handoff to specialized Remotion skills for video production, content scripting, and animation.
## When to Chain
| Task | Chain To |
|------|----------|
| Scene transitions, visual effects, rendering output | `remotion-video` |
| Data-driven videos, captions, templates, dynamic props | `remotion-content` |
| Spring physics, interpolation, easing, timing | `remotion-animation` |
| Framer Motion for UI (not video) | `motion-design` |
---
## Project Setup
### New Project
```bash
# Create new Remotion project
npx create-video@latest my-video
# Or with a template
npx create-video@latest --template hello-world
npx create-video@latest --template tiktok
npx create-video@latest --template three
npx create-video@latest --template audiogram
# Start Remotion Studio (live preview)
npx remotion studio
```
### Add to Existing React Project
```bash
npm i remotion @remotion/cli @remotion/bundler
```
```ts
// remotion.config.ts
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
```
### Project Structure
```
my-video/
├── src/
│ ├── Root.tsx # Register all compositions
│ ├── compositions/
│ │ ├── MyVideo.tsx # Video component
│ │ └── schema.ts # Zod props schema
│ ├── components/ # Reusable visual components
│ └── lib/ # Utilities, data fetching
├── public/ # Static assets (use staticFile())
├── remotion.config.ts # CLI configuration
└── package.json
```
---
## Composition Architecture
### Root Registration
```tsx
// src/Root.tsx
import { Composition } from "remotion";
import { z } from "zod";
import { MyVideo } from "./compositions/MyVideo";
import { myVideoSchema, calcMyVideoMetadata } from "./compositions/schema";
export const RemotionRoot = () => (
<>
<Composition
id="MyVideo"
component={MyVideo}
durationInFrames={300}
fps={30}
width={1920}
height={1080}
schema={myVideoSchema}
defaultProps={{
title: "Hello World",
data: null,
}}
calculateMetadata={calcMyVideoMetadata}
/>
{/* Multiple compositions in one project */}
<Composition
id="Shorts"
component={MyVideo}
durationInFrames={450}
fps={30}
width={1080}
height={1920}
defaultProps={{ title: "Short Video", data: null }}
/>
</>
);
```
### Composition with Zod Schema
```tsx
// src/compositions/schema.ts
import { z } from "zod";
import { CalculateMetadataFunction } from "remotion";
export const myVideoSchema = z.object({
title: z.string(),
data: z.nullable(z.object({
items: z.array(z.object({
name: z.string(),
value: z.number(),
})),
duration: z.number().optional(),
})),
});
type Props = z.infer<typeof myVideoSchema>;
export const calcMyVideoMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const response = await fetch(`https://api.example.com/video-data`);
const data = await response.json();
return {
props: { ...props, data },
// Dynamic duration based on data
durationInFrames: data.duration ? data.duration * 30 : 300,
};
};
```
### Core Hooks
```tsx
import {
useCurrentFrame,
useVideoConfig,
AbsoluteFill,
staticFile,
} from "remotion";
export const MyVideo: React.FC = () => {
const frame = useCurrentFrame(); // Current frame number
const { fps, durationInFrames, width, height } = useVideoConfig();
const currentTimeSeconds = frame / fps; // Convert to seconds
return (
<AbsoluteFill style={{ backgroundColor: "white" }}>
<h1>Frame {frame} of {durationInFrames}</h1>
<p>Time: {currentTimeSeconds.toFixed(2)}s</p>
</AbsoluteFill>
);
};
```
---
## Rendering Pipeline
### Local Rendering
```bash
# Render to MP4 (H.264)
npx remotion render MyVideo out/video.mp4
# Render specific frames
npx remotion render MyVideo out/video.mp4 --frames=0-90
# Custom resolution
npx remotion render MyVideo out/video.mp4 --width=1080 --height=1080
# Render as GIF
npx remotion render MyVideo out/video.gif --codec=gif
# Render as WebM (VP8)
npx remotion render MyVideo out/video.webm --codec=vp8
# Render with props
npx remotion render MyVideo out/video.mp4 --props='{"title":"Custom"}'
# Render still image (single frame)
npx remotion still MyVideo out/thumbnail.png --frame=60
# Parallel rendering with concurrency
npx remotion render MyVideo out/video.mp4 --concurrency=4
```
### Programmatic Rendering (Node.js)
```ts
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
async function render() {
const bundled = await bundle({
entryPoint: "./src/index.ts",
webpackOverride: (config) => config,
});
const composition = await selectComposition({
serveUrl: bundled,
id: "MyVideo",
inputProps: { title: "Programmatic" },
});
await renderMedia({
composition,
serveUrl: bundled,
codec: "h264",
outputLocation: "out/video.mp4",
inputProps: { title: "Programmatic" },
});
}
```
### Lambda Rendering (Serverless)
```bash
# Setup
npx remotion lambda policies role
npx remotion lambda sites create src/index.ts --site-name=my-video
# Render
npx remotion lambda render my-video-site MyVideo
# Programmatic
import { renderMediaOnLambda } from "@remotion/lambda/client";
const { renderId, bucketName } = await renderMediaOnLambda({
region: "us-east-1",
functionName: "remotion-render",
serveUrl: siteUrl,
composition: "MyVideo",
inputProps: { title: "Lambda Render" },
codec: "h264",
});
```
---
## Player Component (Web Embedding)
Embed Remotion compositions in any React app for interactive playback without rendering to file.
```bash
npm i @remotion/player
```
```tsx
import { Player } from "@remotion/player";
import { MyVideo } from "./compositions/MyVideo";
function VideoPreview() {
return (
<Player
component={MyVideo}
inputProps={{ title: "Preview" }}
durationInFrames={300}
fps={30}
compositionWidth={1920}
compositionHeight={1080}
style={{ width: "100%", borderRadius: "0.75rem" }}
controls
autoPlay
loop
clickToPlay
// Responsive
renderLoading={() => <div>Loading...</div>}
/>
);
}
```
### Player with Ref (Programmatic Control)
```tsx
import { Player, PlayerRef } from "@remotion/player";
import { useRef, useCallback } from "react";
function ControlledPlayer() {
const playerRef = useRef<PlayerRef>(null);
const seekTo = useCallback((frame: number) => {
playerRef.current?.seekTo(frame);
}, []);
return (
<>
<Player
ref={playerRef}
component={MyVideo}
inputProps={{ title: "Controlled" }}
durationInFrames={300}
fps={30}
compositionWidth={1920}
compositionHeight={1080}
style={{ width: "100%" }}
/>
<div className="flex gap-4 mt-4">
<button onClick={() => playerRef.current?.play()}>Play</button>
<button onClick={() => playerRef.current?.pause()}>Pause</button>
<button onClick={() => seekTo(0)}>Restart</button>
<button onClick={() => seekTo(150)}>Jump to 5s</button>
</div>
</>
);
}
```
---
## Static Assets
```tsx
import { staticFile, Img } from "remotion";
// Files in public/ folder
const logoUrl = staticFile("logo.png");
const fontUrl = staticFile("fonts/Inter.woff2");
const audioUrl = staticFile("bgm.mp3");
// Use <Img> instead of <img> for proper preloading
<Img src={staticFile("photo.jpg")} style={{ width: "100%" }} />
```
### Font Loading
```tsx
import { staticFile } from "remotion";
const fontFamily = "Inter";
const fontUrl = staticFile("fonts/Inter-Bold.woff2");
// Load font
const style = `
@font-face {
font-family: '${fontFamily}';
src: url('${fontUrl}') format('woff2');
font-weight: 700;
}
`;
export const WithFont: React.FC = () => (
<>
<style>{style}</style>
<div style={{ fontFamily }}>Hello with custom font</div>
</>
);
```
---
## Best Practices
1. **Use `<OffthreadVideo>` over `<Video>`** — Better rendering performance, extracts frames without blocking.
2. **Schema with Zod** — Validate all composition props with Zod schemas for type safety and Remotion Studio UI.
3. **`calculateMetadata` for data fetching** — Fetch data before render starts, not inside components.
4. **`staticFile()` for assets** — Always use `staticFile()` for files in `public/`, never relative paths.
5. **`<Img>` over `<img>`** — Remotion's `<Img>` delays rendering until loaded, preventing blank frames.
6. **`AbsoluteFill` as root** — Use `<AbsoluteFill>` as the root of every scene for proper positioning.
7. **Deterministic renders** — No `Math.random()`, no `Date.now()`. Use `frame` and props for all values.
8. **Keep compositions pure** — No side effects, no async operations inside render. Use `delayRender`/`continueRender` for async.
## Common Pitfalls
| Pitfall | Fix |
|---------|-----|
| Using `<img>` instead of `<Img>` | Blank frames — `<Img>` waits for load |
| `Math.random()` in render | Non-deterministic frames — use `random(seed)` from `remotion` |
| Importing Remotion in production app | Bundle bloat — Remotion is for video rendering only |
| Missing `extrapolateRight: "clamp"` | Values overshoot beyond input range |
| No Zod schema | Remotion Studio can't generate prop UI |
| Fetching data in component render | Use `calculateMetadata` or `delayRender` instead |
---
## From `remotion-video`
> Remotion video production — scenes, transitions, OffthreadVideo, audio mixing, multi-scene timelines, and rendering output
# Remotion Video Production
## Purpose
Expert guidance on building multi-scene video compositions with Remotion. Covers scene organization with `Sequence`/`Series`/`TransitionSeries`, transition effects, `OffthreadVideo` for embedding video files, audio mixing and synchronization, and rendering output configuration.
---
## Scene Organization
### Sequence (Manual Timing)
Position scenes manually with frame offsets:
```tsx
import { Sequence, AbsoluteFill } from "remotion";
export const MyVideo: React.FC = () => (
<AbsoluteFill>
{/* Intro: frames 0-89 (3 seconds at 30fps) */}
<Sequence from={0} durationInFrames={90}>
<IntroScene />
</Sequence>
{/* Main: frames 90-269 (6 seconds) */}
<Sequence from={90} durationInFrames={180}>
<MainScene />
</Sequence>
{/* Outro: frames 270-359 (3 seconds) */}
<Sequence from={270} durationInFrames={90}>
<OutroScene />
</Sequence>
{/* Background music spans entire video */}
<Sequence from={0}>
<BackgroundMusic />
</Sequence>
</AbsoluteFill>
);
```
### Series (Sequential, No Overlap)
Scenes play back-to-back automatically:
```tsx
import { Series, AbsoluteFill } from "remotion";
export const MyVideo: React.FC = () => (
<AbsoluteFill>
<Series>
<Series.Sequence durationInFrames={90}>
<IntroScene />
</Series.Sequence>
{/* Optional gap between scenes */}
<Series.Sequence offset={15} durationInFrames={180}>
<MainScene />
</Series.Sequence>
<Series.Sequence durationInFrames={90}>
<OutroScene />
</Series.Sequence>
</Series>
</AbsoluteFill>
);
```
### TransitionSeries (Crossfades & Wipes)
Scenes overlap during transitions:
```tsx
import { AbsoluteFill } from "remotion";
import { TransitionSeries, linearTiming, springTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { wipe } from "@remotion/transitions/wipe";
export const MyVideo: React.FC = () => (
<AbsoluteFill>
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={90}>
<IntroScene />
</TransitionSeries.Sequence>
{/* Fade transition (30 frames = 1 second) */}
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 30 })}
/>
<TransitionSeries.Sequence durationInFrames={180}>
<MainScene />
</TransitionSeries.Sequence>
{/* Slide transition with spring physics */}
<TransitionSeries.Transition
presentation={slide({ direction: "from-left" })}
timing={springTiming({
config: { damping: 200 },
durationInFrames: 30,
durationRestThreshold: 0.001,
})}
/>
<TransitionSeries.Sequence durationInFrames={90}>
<OutroScene />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
```
### Transition Types
```tsx
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { wipe } from "@remotion/transitions/wipe";
import { flip } from "@remotion/transitions/flip";
import { clockWipe } from "@remotion/transitions/clock-wipe";
import { none } from "@remotion/transitions/none";
// Fade (crossfade)
fade()
// Slide (from direction)
slide({ direction: "from-left" }) // "from-left" | "from-right" | "from-top" | "from-bottom"
// Wipe (directional reveal)
wipe({ direction: "from-left" })
// Flip (3D card flip)
flip({ direction: "from-left" })
// Clock wipe (circular reveal)
clockWipe({ width: 1920, height: 1080 })
// None (cut, useful for conditional transitions)
none()
```
### Overlay Effects (No Timing Impact)
```tsx
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={90}>
<SceneA />
</TransitionSeries.Sequence>
{/* Overlay renders on top without shortening timeline */}
<TransitionSeries.Overlay durationInFrames={20}>
<LightLeakEffect />
</TransitionSeries.Overlay>
<TransitionSeries.Sequence durationInFrames={90}>
<SceneB />
</TransitionSeries.Sequence>
</TransitionSeries>
```
### Custom Transitions
```tsx
import { TransitionPresentation } from "@remotion/transitions";
const customSlideUp: TransitionPresentation = {
component: ({ progress, presenting, children }) => (
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
transform: presenting
? `translateY(${(1 - progress) * 100}%)`
: `translateY(${progress * -30}%)`,
opacity: presenting ? 1 : 1 - progress * 0.3,
}}
>
{children}
</div>
),
};
// Usage
<TransitionSeries.Transition
presentation={customSlideUp}
timing={springTiming({ config: { damping: 200 }, durationInFrames: 30 })}
/>
```
---
## OffthreadVideo
Use `<OffthreadVideo>` instead of `<Video>` for better rendering performance. It extracts frames as images during render (no video decoding in the browser).
```tsx
import { OffthreadVideo, staticFile, AbsoluteFill } from "remotion";
export const VideoScene: React.FC = () => (
<AbsoluteFill>
{/* Background video */}
<OffthreadVideo
src={staticFile("background.mp4")}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
{/* Overlay content */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<h1 style={{ color: "white", fontSize: 80, textShadow: "0 4px 20px rgba(0,0,0,0.5)" }}>
Title Over Video
</h1>
</AbsoluteFill>
</AbsoluteFill>
);
```
### OffthreadVideo with Timing
```tsx
import { OffthreadVideo, Sequence } from "remotion";
// Play from 5 seconds into the source video
<OffthreadVideo
src={staticFile("footage.mp4")}
startFrom={150} // Frame 150 = 5 seconds at 30fps
/>
// Mute video audio
<OffthreadVideo
src={staticFile("footage.mp4")}
volume={0}
/>
// Fade volume
<OffthreadVideo
src={staticFile("footage.mp4")}
volume={(f) => interpolate(f, [0, 30], [0, 1], { extrapolateRight: "clamp" })}
/>
```
---
## Audio
### Basic Audio
```tsx
import { Audio, staticFile, useCurrentFrame, interpolate } from "remotion";
export const WithAudio: React.FC = () => {
const frame = useCurrentFrame();
// Fade in over 1 second
const volume = interpolate(frame, [0, 30], [0, 0.8], {
extrapolateRight: "clamp",
});
return (
<>
<Audio src={staticFile("bgm.mp3")} volume={volume} />
<Audio src={staticFile("sfx-whoosh.mp3")} volume={0.5} startFrom={0} />
</>
);
};
```
### Audio with Fade Out
```tsx
export const FadeOutAudio: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const volume = interpolate(
frame,
[0, 30, durationInFrames - 30, durationInFrames],
[0, 0.8, 0.8, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return <Audio src={staticFile("music.mp3")} volume={volume} />;
};
```
### Audio Visualization
```tsx
import { getAudioData, useAudioData, visualizeAudio } from "@remotion/media-utils";
export const AudioVisualization: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const audioData = useAudioData(staticFile("music.mp3"));
if (!audioData) return null;
const visualization = visualizeAudio({
fps,
frame,
audioData,
numberOfSamples: 256,
});
return (
<AbsoluteFill style={{ alignItems: "flex-end", justifyContent: "center" }}>
<div style={{ display: "flex", gap: 2, alignItems: "flex-end", height: 200 }}>
{visualization.map((v, i) => (
<div
key={i}
style={{
width: 4,
height: v * 200,
backgroundColor: `hsl(${(i / visualization.length) * 360}, 80%, 60%)`,
borderRadius: 2,
}}
/>
))}
</div>
</AbsoluteFill>
);
};
```
---
## Rendering Configuration
### Codec Comparison
| Codec | Format | Quality | Speed | Use Case |
|-------|--------|---------|-------|----------|
| `h264` | MP4 | Good | Fast | Universal playback, social media |
| `h265` | MP4 | Better | Slower | Smaller files, modern devices |
| `vp8` | WebM | Good | Medium | Web embedding |
| `vp9` | WebM | Better | Slow | High quality web |
| `prores` | MOV | Lossless | Fast | Post-production, editing |
| `gif` | GIF | Low | Fast | Short loops, previews |
### Render Settings
```bash
# High quality MP4
npx remotion render MyVideo out/video.mp4 \
--codec=h264 \
--crf=18 \
--concurrency=4
# ProRes for editing
npx remotion render MyVideo out/video.mov \
--codec=prores \
--prores-profile=4444
# Social media vertical (9:16)
npx remotion render Shorts out/short.mp4 \
--width=1080 --height=1920
# Thumbnail / still frame
npx remotion still MyVideo out/thumb.png --frame=60
```
---
## Multi-Scene Patterns
### Scene Component Pattern
```tsx
// Reusable scene wrapper
const Scene: React.FC<{
bg: string;
children: React.ReactNode;
}> = ({ bg, children }) => (
<AbsoluteFill
style={{
backgroundColor: bg,
justifyContent: "center",
alignItems: "center",
padding: 80,
}}
>
{children}
</AbsoluteFill>
);
// Compose scenes
export const MyVideo: React.FC = () => (
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={90}>
<Scene bg="#1a1a2e">
<FadeInTitle text="Welcome" />
</Scene>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 20 })}
/>
<TransitionSeries.Sequence durationInFrames={150}>
<Scene bg="#16213e">
<ContentSlide items={data.items} />
</Scene>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-right" })}
timing={springTiming({ config: { damping: 200 }, durationInFrames: 25 })}
/>
<TransitionSeries.Sequence durationInFrames={90}>
<Scene bg="#0f3460">
<OutroWithCTA />
</Scene>
</TransitionSeries.Sequence>
</TransitionSeries>
);
```
## Best Practices
1. **`<OffthreadVideo>` over `<Video>`** — Always prefer `OffthreadVideo` for rendering; use `<Video>` only when you need `loop`.
2. **Organize scenes as components** — Each scene is a self-contained React component with its own internal animation.
3. **Use `TransitionSeries` for professional feel** — Cuts between scenes feel amateur; crossfades and slides feel polished.
4. **Audio fade in/out** — Never start or stop audio abruptly. Always fade over 0.5-1 second.
5. **CRF 18-23 for H.264** — Lower CRF = higher quality. 18 is visually lossless, 23 is default.
6. **Test with `npx remotion studio`** — Always preview in Studio before rendering. Catches timing issues early.
## Common Pitfalls
| Pitfall | Fix |
|---------|-----|
| Audio cuts off abruptly | Add fade-out with `interpolate` near `durationInFrames` |
| Transitions don't work with `Series` | Use `TransitionSeries` from `@remotion/transitions` |
| Video freezes during render | Use `<OffthreadVideo>` instead of `<Video>` |
| Overlapping scenes visible | Check `Sequence` `from` + `durationInFrames` don't overlap unintentionally |
| Huge file size | Lower CRF, use H.265, or reduce resolution |
| Audio out of sync | Match audio file sample rate to composition fps timing |
---
## From `remotion-content`
> Remotion content scripting — data-driven videos, dynamic templates, captions/subtitles, calculateMetadata, parameterized rendering, and batch video generation
# Remotion Content Scripting
## Purpose
Build data-driven video templates with Remotion. Covers parameterized rendering, `calculateMetadata` for dynamic props, caption/subtitle systems (SRT, TikTok-style), async data fetching with `delayRender`/`continueRender`, batch rendering from datasets, and reusable video template patterns.
---
## Data-Driven Videos
### calculateMetadata (Recommended)
Fetch data and compute dynamic metadata before rendering starts:
```tsx
// schema.ts
import { z } from "zod";
import { CalculateMetadataFunction } from "remotion";
export const videoSchema = z.object({
productId: z.string(),
product: z.nullable(z.object({
name: z.string(),
price: z.number(),
images: z.array(z.string()),
description: z.string(),
})),
});
type Props = z.infer<typeof videoSchema>;
export const calcMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const res = await fetch(`https://api.store.com/products/${props.productId}`);
const product = await res.json();
// Dynamic duration: 3 seconds per image + 2 second intro/outro
const durationInFrames = (product.images.length * 3 + 4) * 30;
return {
props: { ...props, product },
durationInFrames,
// Can also override fps, width, height
};
};
```
```tsx
// Root.tsx
<Composition
id="ProductVideo"
component={ProductVideo}
schema={videoSchema}
calculateMetadata={calcMetadata}
durationInFrames={300} // Default, overridden by calculateMetadata
fps={30}
width={1080}
height={1080}
defaultProps={{ productId: "abc123", product: null }}
/>
```
### delayRender / continueRender (In-Component Async)
For async operations inside the component itself:
```tsx
import { useState, useEffect, useCallback } from "react";
import { AbsoluteFill, useDelayRender } from "remotion";
export const AsyncComponent: React.FC<{ apiUrl: string }> = ({ apiUrl }) => {
const [data, setData] = useState<any>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender("Fetching data..."));
const fetchData = useCallback(async () => {
try {
const response = await fetch(apiUrl);
const json = await response.json();
setData(json);
continueRender(handle);
} catch (err) {
cancelRender(err);
}
}, [apiUrl, continueRender, cancelRender, handle]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!data) return null;
return (
<AbsoluteFill>
<h1>{data.title}</h1>
</AbsoluteFill>
);
};
```
### Prefetching Assets
```tsx
import { prefetch } from "remotion";
// Prefetch a video/image URL before it's needed
const { free, waitUntilDone } = prefetch("https://cdn.example.com/video.mp4", {
method: "blob-url", // or "base64"
});
// Wait for prefetch to complete
await waitUntilDone();
// Free memory when done
free();
```
---
## Captions & Subtitles
### Parse SRT Files
```tsx
import { parseSrt } from "@remotion/captions";
import type { Caption } from "@remotion/captions";
// In calculateMetadata or with delayRender
const response = await fetch(staticFile("subtitles.srt"));
const text = await response.text();
const { captions } = parseSrt({ input: text });
// captions: Caption[] — array of { text, startMs, endMs, confidence? }
```
### Simple Caption Display
```tsx
import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
import type { Caption } from "@remotion/captions";
const CaptionOverlay: React.FC<{ captions: Caption[] }> = ({ captions }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeMs = (frame / fps) * 1000;
// Find active caption
const activeCaption = captions.find(
(c) => c.startMs <= currentTimeMs && c.endMs > currentTimeMs
);
if (!activeCaption) return null;
return (
<AbsoluteFill style={{ justifyContent: "flex-end", padding: 40 }}>
<div
style={{
backgroundColor: "rgba(0, 0, 0, 0.75)",
color: "white",
fontSize: 48,
fontWeight: 700,
padding: "12px 24px",
borderRadius: 12,
textAlign: "center",
maxWidth: "80%",
alignSelf: "center",
}}
>
{activeCaption.text}
</div>
</AbsoluteFill>
);
};
```
### TikTok-Style Captions (Word Highlighting)
```tsx
import { createTikTokStyleCaptions } from "@remotion/captions";
import type { Caption, TikTokPage } from "@remotion/captions";
import { Sequence, useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
const SWITCH_EVERY_MS = 1200;
const HIGHLIGHT_COLOR = "#39E508";
const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeMs = (frame / fps) * 1000;
const absoluteTimeMs = page.startMs + currentTimeMs;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ fontSize: 80, fontWeight: "bold", textAlign: "center" }}>
{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>
);
};
export const TikTokCaptions: React.FC<{ captions: Caption[] }> = ({
captions,
}) => {
const { fps } = useVideoConfig();
const { pages } = createTikTokStyleCaptions({
captions,
combineTokensWithinMilliseconds: SWITCH_EVERY_MS,
});
return (
<AbsoluteFill>
{pages.map((page, i) => {
const nextPage = pages[i + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = nextPage
? (nextPage.startMs / 1000) * fps
: startFrame + (SWITCH_EVERY_MS / 1000) * fps;
const durationInFrames = Math.max(1, Math.round(endFrame - startFrame));
return (
<Sequence
key={i}
from={Math.round(startFrame)}
durationInFrames={durationInFrames}
>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};
```
### Whisper Integration (AI Transcription)
```bash
npm i @remotion/install-whisper-cpp @remotion/whisper
```
```ts
import { installWhisperCpp } from "@remotion/install-whisper-cpp";
import { transcribe } from "@remotion/whisper";
// Install Whisper.cpp (one-time)
await installWhisperCpp({ version: "1.5.5" });
// Transcribe audio to captions
const result = await transcribe({
inputPath: "public/audio.mp3",
whisperPath: ".whisper",
model: "medium",
tokenLevelTimestamps: true,
});
// result.captions: Caption[] — ready to use with createTikTokStyleCaptions
```
---
## Parameterized Rendering
### Props via CLI
```bash
# Pass props as JSON
npx remotion render ProductVideo out/video.mp4 \
--props='{"productId":"abc123","product":null}'
# Props from file
npx remotion render ProductVideo out/video.mp4 \
--props=./props.json
```
### Props via API
```ts
import { renderMedia, selectComposition } from "@remotion/renderer";
const composition = await selectComposition({
serveUrl: bundled,
id: "ProductVideo",
inputProps: {
productId: "abc123",
product: null, // calculateMetadata will fetch
},
});
await renderMedia({
composition,
serveUrl: bundled,
codec: "h264",
outputLocation: "out/product-abc123.mp4",
});
```
---
## Batch Rendering from Dataset
### Render Multiple Videos
```ts
// render-all.ts
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
const products = [
{ id: "abc123", name: "Widget A" },
{ id: "def456", name: "Widget B" },
{ id: "ghi789", name: "Widget C" },
];
async function renderAll() {
const bundled = await bundle({ entryPoint: "./src/index.ts" });
for (const product of products) {
console.log(`Rendering ${product.name}...`);
const composition = await selectComposition({
serveUrl: bundled,
id: "ProductVideo",
inputProps: { productId: product.id, product: null },
});
await renderMedia({
composition,
serveUrl: bundled,
codec: "h264",
outputLocation: `out/${product.id}.mp4`,
});
console.log(`Done: ${product.name}`);
}
}
renderAll();
```
```bash
npx tsx render-all.ts
```
### Parallel Batch Rendering
```ts
import pLimit from "p-limit";
const limit = pLimit(3); // Max 3 concurrent renders
await Promise.all(
products.map((product) =>
limit(async () => {
const composition = await selectComposition({
serveUrl: bundled,
id: "ProductVideo",
inputProps: { productId: product.id, product: null },
});
await renderMedia({
composition,
serveUrl: bundled,
codec: "h264",
outputLocation: `out/${product.id}.mp4`,
concurrency: 2, // Per-render thread count
});
})
)
);
```
---
## Template Patterns
### Reusable Video Template
```tsx
// templates/SocialPost.tsx
import { z } from "zod";
export const socialPostSchema = z.object({
headline: z.string(),
body: z.string(),
imageUrl: z.string().url(),
brandColor: z.string().default("#3b82f6"),
ctaText: z.string().default("Learn More"),
format: z.enum(["square", "story", "landscape"]).default("square"),
});
type Props = z.infer<typeof socialPostSchema>;
const FORMATS = {
square: { width: 1080, height: 1080 },
story: { width: 1080, height: 1920 },
landscape: { width: 1920, height: 1080 },
};
export const calcSocialPostMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => ({
...FORMATS[props.format],
durationInFrames: 150, // 5 seconds
});
export const SocialPost: React.FC<Props> = ({
headline,
body,
imageUrl,
brandColor,
ctaText,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Animate headline entrance
const headlineOpacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateRight: "clamp",
});
const headlineY = spring({ frame, fps, config: { damping: 15 } });
return (
<AbsoluteFill style={{ backgroundColor: brandColor }}>
<Img src={imageUrl} style={{ width: "100%", height: "60%", objectFit: "cover" }} />
<div style={{ padding: 40, color: "white" }}>
<h1
style={{
fontSize: 56,
opacity: headlineOpacity,
transform: `translateY(${interpolate(headlineY, [0, 1], [30, 0])}px)`,
}}
>
{headline}
</h1>
<p style={{ fontSize: 28, marginTop: 16, opacity: headlineOpacity }}>{body}</p>
</div>
</AbsoluteFill>
);
};
```
---
## Best Practices
1. **`calculateMetadata` over `delayRender`** — Prefer `calculateMetadata` for data fetching; it runs once before render and can set dynamic duration/resolution.
2. **Zod schemas for all templates** — Enables Remotion Studio prop editor UI and type safety.
3. **Separate data from presentation** — Keep fetching logic in `calculateMetadata`, rendering logic in components.
4. **Batch with concurrency limits** — Don't render too many videos in parallel; 2-4 concurrent renders is optimal.
5. **Cache API responses** — If rendering multiple videos from the same API, cache responses to avoid rate limits.
6. **TikTok captions: 1-2 second grouping** — `combineTokensWithinMilliseconds: 1200` reads naturally.
7. **Test templates with edge cases** — Long text, missing images, empty arrays. Templates must handle all valid inputs.
## Common Pitfalls
| Pitfall | Fix |
|---------|-----|
| `calculateMetadata` data not serializable | All data must be JSON-serializable (no Date objects, functions) |
| `delayRender` timeout | Default 30s timeout; increase with `delayRender("msg", { timeoutInMilliseconds: 60000 })` |
| Captions out of sync | Verify SRT timestamps match actual audio timing |
| Batch render OOM | Limit concurrency with `p-limit`, reduce per-render `concurrency` |
| Template breaks on edge case | Validate props with Zod, add fallback defaults |
| `continueRender` never called | Always wrap in try-catch; use `cancelRender` on error |
---
## From `remotion-animation`
> Remotion animation patterns — spring physics, interpolate, Easing curves, timing, stagger, enter/exit, and complex motion sequences
# Remotion Animation Patterns
## Purpose
Master animation in Remotion using `interpolate`, `spring`, `Easing`, and `interpolateColors`. Covers timing patterns, staggered entrances, text animations, complex motion sequences, and performance-optimized animation techniques for programmatic video.
---
## Core: interpolate
Map frame numbers to any value range:
```tsx
import { useCurrentFrame, interpolate, AbsoluteFill } from "remotion";
export const BasicAnimation: React.FC = () => {
const frame = useCurrentFrame();
// Opacity: fade in over 1 second (30 frames)
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateRight: "clamp",
});
// Position: slide up 40px
const translateY = interpolate(frame, [0, 30], [40, 0], {
extrapolateRight: "clamp",
});
// Scale: grow from 80% to 100%
const scale = interpolate(frame, [0, 30], [0.8, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Rotation: spin 360 degrees over 2 seconds
const rotation = interpolate(frame, [0, 60], [0, 360]);
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div
style={{
opacity,
transform: `translateY(${translateY}px) scale(${scale}) rotate(${rotation}deg)`,
}}
>
Hello
</div>
</AbsoluteFill>
);
};
```
### Multi-Segment Interpolation
```tsx
// Multi-step animation: appear, hold, disappear
const opacity = interpolate(
frame,
[0, 20, 80, 100], // 4 keyframes
[0, 1, 1, 0], // Fade in, hold, fade out
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Bounce-like: scale up, overshoot, settle
const scale = interpolate(
frame,
[0, 15, 25, 35],
[0, 1.2, 0.95, 1],
{ extrapolateRight: "clamp" }
);
```
### interpolateColors
```tsx
import { interpolateColors, useCurrentFrame } from "remotion";
const frame = useCurrentFrame();
// Smooth color transition
const color = interpolateColors(
frame,
[0, 30, 60],
["#3b82f6", "#8b5cf6", "#ec4899"]
);
// Use in styles
<div style={{ backgroundColor: color }}>Gradient over time</div>
```
---
## Core: spring
Physics-based animation with natural feel:
```tsx
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Basic spring (0 → 1)
const progress = spring({ frame, fps });
// Custom spring config
const bouncy = spring({
frame,
fps,
config: {
damping: 8, // Lower = more bouncy (default: 10)
stiffness: 200, // Higher = faster (default: 100)
mass: 0.5, // Lower = lighter, faster (default: 1)
},
});
// Spring with custom range
const scale = spring({
frame,
fps,
from: 0.5, // Start value
to: 1, // End value
config: { damping: 12, stiffness: 200 },
});
// Delayed spring (starts at frame 30)
const delayed = spring({
frame: frame - 30, // Negative frames return 0
fps,
config: { damping: 15 },
});
```
### Spring Configurations
| Feel | damping | stiffness | mass | Use Case |
|------|---------|-----------|------|----------|
| Snappy | 20 | 300 | 0.5 | UI elements, quick pops |
| Bouncy | 8 | 200 | 1 | Playful, attention-grabbing |
| Smooth | 15 | 100 | 1 | Gentle entrances |
| Stiff | 30 | 400 | 0.5 | Sharp, decisive motion |
| Heavy | 12 | 80 | 2 | Weighty, impactful |
| Elastic | 5 | 150 | 0.8 | Rubber-band, fun |
### Combining spring + interpolate
Map spring progress to custom ranges:
```tsx
const springProgress = spring({
frame,
fps,
config: { damping: 12, stiffness: 200 },
});
// Map spring 0→1 to rotation 0→360
const rotation = interpolate(springProgress, [0, 1], [0, 360]);
// Map spring 0→1 to position
const translateX = interpolate(springProgress, [0, 1], [-200, 0]);
// Map spring 0→1 to scale with overshoot range
const scale = interpolate(springProgress, [0, 0.5, 1], [0, 1.15, 1]);
```
---
## Easing Functions
Custom easing curves for non-spring interpolations:
```tsx
import { Easing, interpolate, useCurrentFrame } from "remotion";
const frame = useCurrentFrame();
// Built-in easings
const easeIn = interpolate(frame, [0, 30], [0, 1], {
easing: Easing.in(Easing.ease),
extrapolateRight: "clamp",
});
const easeOut = interpolate(frame, [0, 30], [0, 1], {
easing: Easing.out(Easing.ease),
extrapolateRight: "clamp",
});
const easeInOut = interpolate(frame, [0, 30], [0, 1], {
easing: Easing.inOut(Easing.ease),
extrapolateRight: "clamp",
});
// Cubic bezier (like CSS)
const custom = interpolate(frame, [0, 30], [0, 1], {
easing: Easing.bezier(0.4, 0, 0.2, 1), // Material Design standard
extrapolateRight: "clamp",
});
// Elastic
const elastic = interpolate(frame, [0, 60], [0, 1], {
easing: Easing.elastic(2),
extrapolateRight: "clamp",
});
// Bounce
const bounce = interpolate(frame, [0, 60], [0, 1], {
easing: Easing.bounce,
extrapolateRight: "clamp",
});
```
### Easing Reference
| Easing | Effect | Use Case |
|--------|--------|----------|
| `Easing.linear` | Constant speed | Progress bars only |
| `Easing.ease` | Smooth acceleration/deceleration | General purpose |
| `Easing.in(Easing.ease)` | Slow start, fast end | Elements leaving |
| `Easing.out(Easing.ease)` | Fast start, slow end | Elements entering |
| `Easing.inOut(Easing.ease)` | Smooth both ends | Elements moving |
| `Easing.bezier(...)` | Custom curve | Precise control |
| `Easing.elastic(n)` | Spring-like overshoot | Playful entrances |
| `Easing.bounce` | Bouncing ball | Fun, attention-grabbing |
| `Easing.circle` | Circular curve | Subtle acceleration |
| `Easing.exp` | Exponential | Dramatic speed changes |
---
## Staggered Animations
### Stagger Array Items
```tsx
const items = ["Design", "Develop", "Deploy"];
const STAGGER_DELAY = 8; // Frames between each item
export const StaggeredList: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<AbsoluteFill style={{ justifyContent: "center", padding: 80 }}>
{items.map((item, index) => {
const delay = index * STAGGER_DELAY;
const opacity = interpolate(frame - delay, [0, 20], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const translateX = spring({
frame: frame - delay,
fps,
config: { damping: 15, stiffness: 200 },
});
return (
<div
key={item}
style={{
fontSize: 64,
fontWeight: 700,
opacity,
transform: `translateX(${interpolate(translateX, [0, 1], [-60, 0])}px)`,
marginBottom: 20,
}}
>
{item}
</div>
);
})}
</AbsoluteFill>
);
};
```
### Stagger with Exit
```tsx
const ENTER_DELAY = 8;
const EXIT_START = 80; // Frame when exit begins
const EXIT_DELAY = 5;
items.map((item, index) => {
// Enter
const enterOffset = index * ENTER_DELAY;
const enterOpacity = interpolate(frame - enterOffset, [0, 15], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Exit (reverse stagger — last item exits first)
const exitOffset = (items.length - 1 - index) * EXIT_DELAY;
const exitFrame = EXIT_START + exitOffset;
const exitOpacity = interpolate(frame, [exitFrame, exitFrame + 15], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = Math.min(enterOpacity, exitOpacity);
// ...
});
```
---
## Text Animations
### Character-by-Character Reveal
```tsx
export const TypewriterText: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const charsToShow = Math.floor(
interpolate(frame, [0, text.length * 2], [0, text.length], {
extrapolateRight: "clamp",
})
);
return (
<div style={{ fontSize: 48, fontFamily: "monospace" }}>
{text.slice(0, charsToShow)}
{charsToShow < text.length && (
<span style={{ opacity: frame % 15 < 8 ? 1 : 0 }}>|</span>
)}
</div>
);
};
```
### Word-by-Word Spring Entrance
```tsx
export const WordSpring: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = text.split(" ");
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 12, fontSize: 64, fontWeight: 700 }}>
{words.map((word, i) => {
const delay = i * 5;
const progress = spring({
frame: frame - delay,
fps,
config: { damping: 12, stiffness: 200 },
});
return (
<span
key={i}
style={{
opacity: interpolate(progress, [0, 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateY(${interpolate(progress, [0, 1], [30, 0])}px)`,
display: "inline-block",
}}
>
{word}
</span>
);
})}
</div>
);
};
```
### Counter Animation
```tsx
export const Counter: React.FC<{ from: number; to: number }> = ({ from, to }) => {
const frame = useCurrentFrame();
const value = interpolate(frame, [0, 60], [from, to], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.ease),
});
return (
<div style={{ fontSize: 120, fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>
{Math.round(value).toLocaleString()}
</div>
);
};
```
---
## Complex Motion Sequences
### Chained Animations (Sequential)
```tsx
export const ChainedMotion: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Phase 1: Logo enters (frames 0-30)
const logoScale = spring({
frame,
fps,
config: { damping: 12, stiffness: 200 },
});
// Phase 2: Title slides in (frames 20-50)
const titleProgress = spring({
frame: frame - 20,
fps,
config: { damping: 15, stiffness: 150 },
});
const titleX = interpolate(titleProgress, [0, 1], [-100, 0]);
const titleOpacity = interpolate(titleProgress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// Phase 3: Subtitle fades in (frames 40-60)
const subtitleOpacity = interpolate(frame, [40, 60], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Phase 4: CTA bounces in (frames 55-85)
const ctaScale = spring({
frame: frame - 55,
fps,
config: { damping: 8, stiffness: 200 },
});
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", gap: 20 }}>
<div style={{ transform: `scale(${logoScale})` }}>Logo</div>
<h1 style={{ transform: `translateX(${titleX}px)`, opacity: titleOpacity }}>
Title
</h1>
<p style={{ opacity: subtitleOpacity }}>Subtitle text here</p>
<button style={{ transform: `scale(${ctaScale})` }}>Call to Action</button>
</AbsoluteFill>
);
};
```
### Looping Animation
```tsx
// Continuous rotation (loops every 2 seconds)
const rotation = interpolate(frame % 60, [0, 60], [0, 360]);
// Pulsing scale
const pulse = interpolate(
Math.sin((frame / 30) * Math.PI),
[-1, 1],
[0.95, 1.05]
);
// Floating (smooth up/down)
const float = Math.sin((frame / fps) * Math.PI * 0.5) * 10;
```
### Path Animation
```tsx
// Move along a circular path
const angle = interpolate(frame, [0, 120], [0, Math.PI * 2]);
const radius = 150;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
<div style={{ transform: `translate(${x}px, ${y}px)` }}>
Moving in a circle
</div>
// Move along a bezier-like path
const t = interpolate(frame, [0, 60], [0, 1], { extrapolateRight: "clamp" });
const bezierX = interpolate(t, [0, 0.5, 1], [0, 200, 400]);
const bezierY = interpolate(t, [0, 0.5, 1], [0, -100, 0]); // Arc
```
---
## Noise & Organic Motion
```tsx
import { random } from "remotion";
// Deterministic random (same for every render)
const wobbleX = random(`wobble-x-${frame}`) * 4 - 2; // -2 to 2
const wobbleY = random(`wobble-y-${frame}`) * 4 - 2;
// Smooth noise via sin combination
const smoothNoise = (
Math.sin(frame * 0.1) * 3 +
Math.sin(frame * 0.23) * 2 +
Math.sin(frame * 0.37) * 1
);
// Handheld camera shake
const shakeX = Math.sin(frame * 0.5) * 2 + Math.cos(frame * 0.7) * 1.5;
const shakeY = Math.cos(frame * 0.4) * 2 + Math.sin(frame * 0.9) * 1;
const shakeRotation = Math.sin(frame * 0.3) * 0.5;
```
---
## Best Practices
1. **Always clamp extrapolation** — Use `extrapolateRight: "clamp"` to prevent values overshooting beyond the target range.
2. **Spring for organic, interpolate for precise** — Springs feel natural; interpolate gives exact timing control.
3. **Stagger at 5-10 frame intervals** — Too fast looks simultaneous, too slow loses cohesion.
4. **Chain with frame offsets** — Start phase 2 before phase 1 ends for overlapping, fluid sequences.
5. **Use `random(seed)` not `Math.random()`** — Deterministic rendering requires deterministic randomness.
6. **Test at 1x speed** — Don't judge animation timing in slow-motion preview.
7. **Reuse spring configs** — Define configs as constants for consistent animation language across the video.
## Common Pitfalls
| Pitfall | Fix |
|---------|-----|
| Missing `extrapolateRight: "clamp"` | Values go beyond intended range (opacity > 1, negative positions) |
| `Math.random()` in render | Different frame = different random value = flickering. Use `random(seed)` |
| Spring with negative frame | Returns `from` value — this is correct behavior, not a bug |
| Over-complicated interpolation | Break complex animations into multiple simple interpolations |
| No easing on interpolate | Linear motion looks robotic — add `easing: Easing.out(Easing.ease)` |
| Stagger delay too small | Items animate simultaneously — use at least 5 frames between items |
---
## From `motion-design`
> Motion design for web — Framer Motion patterns, Remotion video composition, 12 Disney animation principles applied to UI, easing curves, and accessible motion
# Motion Design
## Purpose
Expert guidance on motion design for web applications: Framer Motion for UI animation, Remotion for programmatic video, the 12 Disney animation principles mapped to digital interfaces, easing curve selection, and accessible motion that respects `prefers-reduced-motion`.
---
## Framer Motion
### Core API Patterns
#### Basic Animation
```tsx
import { motion } from "framer-motion";
// Animate on mount
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
// Hover + tap
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
/>
```
#### Variants (Declarative Animation States)
```tsx
const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -10 },
};
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ duration: 0.3 }}
/>
```
#### Stagger Children
```tsx
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
};
<motion.ul variants={containerVariants} initial="hidden" animate="visible">
{items.map((item) => (
<motion.li key={item.id} variants={itemVariants} />
))}
</motion.ul>
```
#### AnimatePresence (Enter/Exit Animations)
```tsx
import { AnimatePresence, motion } from "framer-motion";
<AnimatePresence mode="wait">
{isVisible && (
<motion.div
key="content"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
```
`mode` options:
- `"sync"` (default) — Enter and exit animate simultaneously
- `"wait"` — Exit completes before enter starts
- `"popLayout"` — Exiting elements removed from layout flow immediately
#### Layout Animations
```tsx
// Automatic layout animation when position/size changes
<motion.div layout />
// Shared layout animation between components
<motion.div layoutId="shared-element" />
// Layout with transition control
<motion.div
layout
transition={{ layout: { duration: 0.3, ease: "easeInOut" } }}
/>
```
#### Scroll Animations
```tsx
import { motion, useScroll, useTransform } from "framer-motion";
function ParallaxHero() {
const { scrollYProgress } = useScroll();
const y = useTransform(scrollYProgress, [0, 1], [0, -200]);
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]);
return (
<motion.div style={{ y, opacity }}>
Hero content
</motion.div>
);
}
```
Scroll-linked with element ref:
```tsx
function ScrollReveal({ children }: { children: ReactNode }) {
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const opacity = useTransform(scrollYProgress, [0, 0.3], [0, 1]);
const y = useTransform(scrollYProgress, [0, 0.3], [40, 0]);
return (
<motion.div ref={ref} style={{ opacity, y }}>
{children}
</motion.div>
);
}
```
#### Gesture Handlers
```tsx
<motion.div
drag // Enable drag on both axes
drag="x" // Constrain to x-axis
dragConstraints={{ left: -100, right: 100 }}
dragElastic={0.2} // Rubber-band past constraints (0-1)
dragTransition={{ bounceStiffness: 300, bounceDamping: 20 }}
onDragEnd={(event, info) => {
if (info.offset.x > 100) handleSwipeRight();
if (info.offset.x < -100) handleSwipeLeft();
}}
whileDrag={{ scale: 1.05, cursor: "grabbing" }}
/>
```
#### Shared Layout (Cross-Component)
```tsx
import { LayoutGroup, motion } from "framer-motion";
// Wrap related components
<LayoutGroup>
{tabs.map((tab) => (
<button key={tab.id} onClick={() => setActive(tab.id)}>
{tab.label}
{active === tab.id && (
<motion.div
layoutId="active-tab-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
/>
)}
</button>
))}
</LayoutGroup>
```
### Framer Motion Performance Tips
```
1. Use `transform` and `opacity` only — these are GPU-accelerated
2. Avoid animating `width`, `height`, `padding`, `margin` directly
3. Use `layout` prop for size/position changes instead
4. Set `layoutScroll` on scrollable containers to fix layout calc
5. Use `will-change: transform` sparingly (Framer adds it automatically)
6. Prefer `spring` over `tween` — springs feel more natural and don't need duration
7. Memoize variants objects outside components to prevent re-renders
8. Use `useReducedMotion()` hook to conditionally disable animations
```
---
## Remotion
### Core Concepts
Remotion renders React components as video frames. Each frame is a render at a specific `frame` number.
#### Composition Setup
```tsx
// src/Root.tsx
import { Composition } from "remotion";
import { MyVideo } from "./MyVideo";
export const RemotionRoot = () => (
<Composition
id="MyVideo"
component={MyVideo}
durationInFrames={300} // 10 seconds at 30fps
fps={30}
width={1920}
height={1080}
defaultProps={{ title: "Hello" }}
/>
);
```
#### Using Frame and Time
```tsx
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
export const MyVideo = ({ title }: { title: string }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
const opacity = interpolate(
frame,
[0, 30], // Input range (frames 0-30)
[0, 1], // Output range (opacity 0-1)
{ extrapolateRight: "clamp" }
);
const translateY = interpolate(
frame,
[0, 30],
[40, 0],
{ extrapolateRight: "clamp" }
);
return (
<div style={{ opacity, transform: `translateY(${translateY}px)` }}>
<h1>{title}</h1>
</div>
);
};
```
#### Sequences (Timeline Segments)
```tsx
import { Sequence } from "remotion";
export const Timeline = () => (
<>
<Sequence from={0} durationInFrames={90}>
<IntroScene />
</Sequence>
<Sequence from={90} durationInFrames={120}>
<MainContent />
</Sequence>
<Sequence from={210} durationInFrames={90}>
<OutroScene />
</Sequence>
</>
);
```
#### Audio
```tsx
import { Audio, staticFile, interpolate, useCurrentFrame } from "remotion";
export const WithAudio = () => {
const frame = useCurrentFrame();
const volume = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
return (
<>
<Audio src={staticFile("bgm.mp3")} volume={volume} />
<VideoContent />
</>
);
};
```
#### Spring Animations in Remotion
```tsx
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
export const SpringAnimation = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = spring({
frame,
fps,
config: { damping: 12, stiffness: 200, mass: 0.5 },
});
return <div style={{ transform: `scale(${scale})` }}>Bouncy!</div>;
};
```
### Rendering Pipeline
```bash
# Preview in browser
npx remotion studio
# Render to MP4
npx remotion render MyVideo out/video.mp4
# Render specific frames
npx remotion render MyVideo out/video.mp4 --frames=0-90
# Render as GIF
npx remotion render MyVideo out/video.gif --codec=gif
# Render at custom resolution
npx remotion render MyVideo out/video.mp4 --width=1080 --height=1080
# Server-side rendering (Lambda)
npx remotion lambda render MyVideo
```
### Remotion Best Practices
```
1. Keep compositions pure — no side effects, no async in render
2. Use `staticFile()` for assets in the `public/` folder
3. Use `<Img>` component instead of `<img>` for preloading
4. Prefetch large assets with `prefetch()` or `delayRender()` / `continueRender()`
5. Use `interpolate()` with `extrapolateRight: "clamp"` to prevent value overflow
6. Keep fps consistent (30 for social media, 60 for presentations)
7. Use `<Series>` for sequential, non-overlapping scenes (simpler than manual offsets)
```
---
## 12 Disney Animation Principles (Web-Mapped)
The 12 principles of animation, mapped to practical web interactions:
### 1. Squash and Stretch
Conveys weight and flexibility. In web: scale transforms on interaction.
```tsx
whileTap={{ scaleX: 1.05, scaleY: 0.95 }} // Button press
whileHover={{ scaleY: 1.02 }} // Card lift
```
### 2. Anticipation
Prepare the user for an action. In web: brief reverse movement before primary motion.
```tsx
// Slight dip before jump
animate={{ y: [0, 4, -20, 0] }}
transition={{ duration: 0.5, times: [0, 0.15, 0.6, 1] }}
```
### 3. Staging
Direct attention to the important element. In web: dim surroundings, spotlight focal point.
```tsx
// Overlay dims background, modal is staged
<motion.div className="bg-black/50" animate={{ opacity: 1 }} />
<motion.div animate={{ scale: 1 }} initial={{ scale: 0.95 }} />
```
### 4. Straight Ahead vs. Pose to Pose
In web: keyframe animations (pose-to-pose) vs. physics-based springs (straight ahead).
```tsx
// Pose to pose (keyframes)
animate={{ x: [0, 100, 200] }}
transition={{ duration: 0.6, times: [0, 0.4, 1] }}
// Straight ahead (spring physics)
transition={{ type: "spring", stiffness: 300, damping: 20 }}
```
### 5. Follow-Through and Overlapping Action
Elements don't stop at the same time. In web: stagger children, spring overshoot.
```tsx
// Spring with overshoot (follow-through)
transition={{ type: "spring", stiffness: 200, damping: 15 }} // Low damping = overshoot
// Overlapping with stagger
staggerChildren: 0.05 // Children offset in time
```
### 6. Slow In, Slow Out (Easing)
Natural motion accelerates and decelerates. In web: ease-in-out, never linear for UI.
```tsx
transition={{ ease: "easeInOut" }} // Standard
transition={{ ease: [0.4, 0, 0.2, 1] }} // Material Design standard
```
### 7. Arcs
Natural motion follows curved paths. In web: use CSS offset-path or animate both x and y.
```tsx
animate={{ x: 100, y: [0, -30, 0] }} // Parabolic arc
```
### 8. Secondary Action
Supporting animations that enhance the primary. In web: icon spin inside button, shimmer on card hover.
```tsx
// Primary: card lifts. Secondary: shadow deepens
whileHover={{ y: -4, boxShadow: "0 12px 24px rgba(0,0,0,0.15)" }}
```
### 9. Timing
Duration defines the feel. Fast = snappy/urgent. Slow = graceful/calm.
```
Micro-interactions: 100-200ms (button clicks, toggles)
Transitions: 200-400ms (page transitions, modals)
Emphasis: 400-800ms (hero animations, onboarding)
Never exceed 1s for UI animations
```
### 10. Exaggeration
Amplify motion for clarity. In web: slightly larger scale changes than physically realistic.
```tsx
// Slightly exaggerated hover lift (more than real physics)
whileHover={{ y: -6, scale: 1.02 }} // Not y: -1 (too subtle)
```
### 11. Solid Drawing (Consistency)
Maintain consistent visual weight and style. In web: consistent animation language.
```
All cards animate the same way
All modals enter/exit the same way
All buttons have the same press feel
Define variants once, reuse everywhere
```
### 12. Appeal
Animation should be pleasing, not annoying. In web: purposeful, restrained, delightful.
```
Don't animate everything — animate the moments that matter
Subtlety > spectacle for professional interfaces
Reserve bold animation for celebrations (confetti, success states)
```
---
## Easing Reference
### Named Easings
| Name | Curve | Use Case |
|------|-------|----------|
| `linear` | `cubic-bezier(0, 0, 1, 1)` | Progress bars, continuous rotation only |
| `ease` | `cubic-bezier(0.25, 0.1, 0.25, 1)` | General purpose (CSS default) |
| `ease-in` | `cubic-bezier(0.42, 0, 1, 1)` | Elements leaving the screen |
| `ease-out` | `cubic-bezier(0, 0, 0.58, 1)` | Elements entering the screen |
| `ease-in-out` | `cubic-bezier(0.42, 0, 0.58, 1)` | Elements moving within the screen |
### Material Design Easings
| Name | Curve | Use Case |
|------|-------|----------|
| Standard | `cubic-bezier(0.4, 0, 0.2, 1)` | General movement |
| Deceleration | `cubic-bezier(0, 0, 0.2, 1)` | Entering elements |
| Acceleration | `cubic-bezier(0.4, 0, 1, 1)` | Leaving elements |
| Sharp | `cubic-bezier(0.4, 0, 0.6, 1)` | Elements that may return |
### Spring Configurations
| Feel | Stiffness | Damping | Mass | Use Case |
|------|-----------|---------|------|----------|
| Snappy | 400 | 25 | 0.5 | Button press, toggle |
| Bouncy | 200 | 15 | 1 | Playful interactions |
| Smooth | 100 | 20 | 1 | Page transitions |
| Gentle | 50 | 10 | 1 | Slow reveals |
| Stiff | 600 | 30 | 0.5 | Quick snaps |
### Choosing Easing
```
Entering screen → ease-out (decelerate into view)
Leaving screen → ease-in (accelerate out of view)
Moving on screen → ease-in-out (smooth transition)
Interactive feedback → spring (natural, responsive feel)
Progress/loading → linear (constant rate)
```
---
## Accessibility: prefers-reduced-motion
### The Requirement
Users who experience motion sickness, vestibular disorders, or simply prefer less animation set `prefers-reduced-motion: reduce` in their OS settings. All motion must respect this preference.
### CSS Approach
```css
/* Default: full animation */
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.15);
}
/* Reduced motion: instant or opacity-only */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
box-shadow: 0 4px 8px rgba(0,0,0,0.1); /* Subtle, no motion */
}
}
```
### Tailwind Approach
```html
<div class="transition-all duration-200 hover:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0">
Content
</div>
```
### Framer Motion Approach
```tsx
import { useReducedMotion } from "framer-motion";
function AnimatedCard({ children }: { children: ReactNode }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.3 }}
>
{children}
</motion.div>
);
}
```
### Vestibular-Safe Alternatives
When reducing motion, don't just remove it — provide alternatives:
| Full Motion | Reduced Alternative |
|------------|-------------------|
| Slide in from side | Fade in (opacity only) |
| Scale up/bounce | Fade in (opacity only) |
| Parallax scrolling | Static positioning |
| Auto-playing animation | User-triggered only |
| Page transition slide | Instant cut or crossfade |
| Continuous rotation | Static icon |
### What's Always Safe
These effects don't trigger vestibular issues and can remain:
- Color changes (hover state color shifts)
- Opacity transitions (fades)
- Border/outline changes
- Box-shadow changes (without position shift)
- Very short, very small transforms (< 5px, < 100ms)
---
## Pitfalls
1. **Animating layout properties** — Never directly animate `width`, `height`, `padding`, `margin`. Use `transform: scale()` or Framer Motion's `layout` prop.
2. **Linear easing for UI** — Linear motion looks robotic. Always use easing or springs for UI interactions.
3. **Missing exit animations** — Abrupt disappearance is jarring. Use `AnimatePresence` for all conditional renders.
4. **Over-animating** — If everything moves, nothing stands out. Animate the moments that matter.
5. **Ignoring prefers-reduced-motion** — This is not optional. It's an accessibility requirement.
6. **Too-long durations** — UI animations > 500ms feel sluggish. Keep under 400ms for most interactions.
7. **Remotion in production apps** — Remotion is for video rendering, not runtime UI. Don't import it into your web app bundle.
8. **Spring without damping** — Underdamped springs oscillate forever. Always set a reasonable damping value (15-30 for most cases).Related Skills
deck-studio
Unified HTML presentation generator — strategic slides with Chart.js and design tokens, polished HTML decks with print/PDF versions and speaker notes, investor pitch decks with problem/solution/market/ask structure. Single entry point for 'make a slide deck' / 'create a presentation' / 'pitch deck'.
brand-studio
Unified brand identity pipeline — brand voice, visual identity, messaging frameworks, asset management, brand consistency, plus full brand kit generator (strategy → color palette → typography → CSS tokens → guidelines). Pipeline: creative vision → brand strategy → full kit.
ultrathink
UltraThink Workflow OS — 4-layer skill mesh with persistent memory and privacy hooks for complex engineering tasks. Routes prompts through intent detection to activate the right domain skills automatically.
ultrathink_review
Multi-pass code review powered by UltraThink's quality gate — checks correctness, security (OWASP), performance, readability, and project conventions in a single structured pass.
ultrathink_memory
Persistent memory system for UltraThink — search, save, and recall project context, decisions, and patterns across sessions using Postgres-backed fuzzy search with synonym expansion.
ui-design
Comprehensive UI design system: 230+ font pairings, 48 themes, 65 design systems, 23 design languages, 30 UX laws, 14 color systems, Swiss grid, Gestalt principles, Pencil.dev workflow. Inherits ui-ux-pro-max (99 UX rules) + impeccable-frontend-design (anti-AI-slop). Triggers on any design, UI, layout, typography, color, theme, or styling task.
Zod
> TypeScript-first schema validation with static type inference.
webinar-registration-page
Build a webinar or live event registration page as a self-contained HTML file with countdown timer, speaker bio, agenda, and registration form. Triggers on: "build a webinar registration page", "create a webinar sign-up page", "event registration landing page", "live training registration page", "workshop sign-up page", "create a webinar page", "build an event page", "free webinar landing page", "live demo registration page", "online event page", "create a registration page for my webinar", "build a training event page".
webhooks
Webhook design patterns — delivery, retry with exponential backoff, HMAC signature verification, payload validation, idempotency keys
web-workers
Offload heavy computation from the main thread using Web Workers, SharedWorkers, and Comlink — structured messaging, transferable objects, and off-main-thread architecture patterns
web-vitals
Core Web Vitals monitoring (LCP, FID, CLS, INP, TTFB), measurement with web-vitals library, reporting to analytics, and optimization strategies for Next.js
web-components
Native Web Components, custom elements API, Shadow DOM, HTML templates, slots, lifecycle callbacks, and framework-agnostic design patterns