Designing Remotion Components That AI Agents Can Discover
By RenderComp Team Editorial policy
Remotion encodes video as a pure function of frame number. Given frame 42 at 30 fps, every pixel is deterministic: no canvas state, no DOM side-effects, no network call that could resolve differently on a second pass. This property makes Remotion uniquely suited for programmatic video generation, a system that composes a sequence of components by writing TypeScript rather than by clicking a timeline editor.
AI coding agents are already doing this. A developer working on a report-generation pipeline might ask an agent to assemble a MetricsSlide composition from several library components, threading frame offsets and duration values through a tree of <Sequence> elements. The agent succeeds if and only if it correctly understands the temporal contracts your components publish. It cannot render a preview to check its work. It reads TypeScript types, JSDoc, and prior usage examples. Its failure mode is not a compile error; it is a composition that renders silently wrong at frame 90.
Designing a Remotion component library that AI agents can use reliably is a discipline distinct from designing for human developers. The affordances differ: humans run the dev server and see the output; agents infer the output from types. The patterns that follow (branded types, JSDoc temporal annotations, prop surface minimization, and exportable component schemas) shift the error rate from silent runtime mismatch to caught-at-compile-time.
The Frame/Second Unit Ambiguity
The most common category of agent mistake in Remotion is unit confusion. A component accepting duration: number is ambiguous: 6 could mean 6 seconds (180 frames at 30 fps) or 6 frames (0.2 seconds). The TypeScript compiler cannot catch this, and an agent writing call sites for dozens of components will guess wrong some percentage of the time.
TypeScript branded types eliminate the ambiguity at the type level. A branded type is a nominal wrapper over a primitive that the compiler treats as structurally distinct:
// temporal-units.ts
export type Frames = number & { readonly __brand: 'frames' }
export type Seconds = number & { readonly __brand: 'seconds' }
export const frames = (n: number): Frames => n as Frames
export const seconds = (n: number): Seconds => n as Seconds
/** Convert seconds to frames using the composition's fps. */
export const toFrames = (s: Seconds, fps: number): Frames =>
Math.round(s * fps) as Frames
A component that requires Frames cannot silently accept a raw number or a Seconds value. The agent must explicitly call toFrames(seconds(6), fps), which both enforces the conversion and makes the unit visible at the call site:
// FadeIn.tsx
import { useCurrentFrame, interpolate } from 'remotion'
import type { Frames } from './temporal-units'
interface FadeInProps {
/** Duration of the fade, in frames. Use toFrames() to convert from seconds. */
durationInFrames: Frames
children: React.ReactNode
}
export const FadeIn: React.FC<FadeInProps> = ({ durationInFrames, children }) => {
const frame = useCurrentFrame()
const opacity = interpolate(
frame,
[0, durationInFrames],
[0, 1],
{ extrapolateRight: 'clamp' }
)
return <div style={{ opacity }}>{children}</div>
}
The durationInFrames: Frames type in the interface is enough for most TypeScript-aware agents to flag mismatched calls. The JSDoc @param note doubles as a fallback when the agent reads the hover documentation rather than the type signature directly.
JSDoc Contracts as Machine-Readable Animation Specs
JSDoc is not just documentation. Language models are trained on it, and IDE tooling surfaces it in completion hints that agents read when generating call sites. An @example block is the highest-signal annotation you can add to an animated component, because it shows the temporal context that types alone cannot.
Consider a spring-based scale component. The spring() function in Remotion takes frame, fps, and a config with stiffness, damping, and optionally mass. An agent that has not seen spring() used before will default to stiffness 100 and damping 10 (the library defaults), but those values produce visible ringing on a 30-frame entrance animation. Encoding the tested configuration directly in the JSDoc means every agent call site starts from a working baseline:
/**
* Scales a child from zero to full size using a spring curve.
*
* @param delay - Frame at which the spring starts. Defaults to 0.
* @param stiffness - Spring stiffness. 120 gives a snappy entrance over
* roughly 18 frames at 30 fps. Values below 80 produce a slower, bouncier
* feel; values above 200 approach a linear pop.
* @param damping - Spring damping. Values below 10 produce visible overshoot.
* The default 14 reaches rest by frame 20 at 30 fps with stiffness 120.
*
* @example
* // Entrance that starts at frame 15, snaps into place by frame 35:
* <SpringScale delay={frames(15)} stiffness={120} damping={14}>
* <Title />
* </SpringScale>
*/
export interface SpringScaleProps {
delay?: Frames
stiffness?: number
damping?: number
children: React.ReactNode
}
The prose in @param stiffness, specifically the note “reaches rest by frame 20 at 30 fps”, is exactly the kind of information an agent cannot derive from the type number. It encodes an empirical measurement that takes a human a few seconds to eyeball in the preview but that an agent cannot replicate without rendering.
Describing interpolate Output Ranges
interpolate() maps an input range to an output range, but the semantic meaning of the output depends entirely on what CSS property the result feeds into. When documenting a component’s internal behavior, especially when the output range determines something non-obvious like blur radius or perspective depth, name the output units explicitly:
/**
* Blurs children out over `durationInFrames` frames, from 0px to `maxBlur`px.
*
* @param maxBlur - Maximum CSS blur in pixels. 8 is visually subtle;
* 40 is an aggressive wipe. Values above 60 may degrade performance
* on compositions wider than 1920px.
*/
export interface BlurOutProps {
durationInFrames: Frames
maxBlur?: number // CSS pixels
children: React.ReactNode
}
Without the // CSS pixels comment and the concrete range note, an agent might pass 1.0 treating it as a 0–1 opacity-style value, producing no visible blur at typical sizes.
Prop Surface Minimization
Every optional prop is a decision point for an agent. A component with twelve configuration knobs generates twelve guesses per call site, and each guess carries a small probability of being wrong. Prop count is a direct lever on the agent’s error rate.
The most reliable way to reduce prop count in a Remotion component is to derive values from useVideoConfig() rather than requiring them as props. useVideoConfig() returns { fps, durationInFrames, width, height, id } for the current composition context, information that is always correct at runtime and never requires the call site to thread it through.
A transition component that ends at the composition boundary does not need an endFrame prop:
// SlideIn.tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring } from 'remotion'
import type { Frames } from './temporal-units'
interface SlideInProps {
/** Frame at which the slide starts. */
startFrame: Frames
/** Direction of travel. */
from: 'left' | 'right' | 'top' | 'bottom'
children: React.ReactNode
}
export const SlideIn: React.FC<SlideInProps> = ({ startFrame, from, children }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const progress = spring({
frame: frame - startFrame,
fps,
config: { stiffness: 110, damping: 16, mass: 1 },
})
const axis = from === 'left' || from === 'right' ? 'X' : 'Y'
const sign = from === 'right' || from === 'bottom' ? 1 : -1
const translate = (1 - progress) * sign * 100
return (
<AbsoluteFill style={{ transform: `translate${axis}(${translate}%)` }}>
{children}
</AbsoluteFill>
)
}
The component reads fps from useVideoConfig() internally; the call site only needs startFrame and from. An agent writing a <SlideIn> call site has two decisions to make, not four.
Enum props over open string props also reduce ambiguity. from: 'left' | 'right' | 'top' | 'bottom' is four valid values; from: string is infinite. TypeScript will autocomplete the valid values and reject others at compile time, which catches agent-generated typos before render.
Exportable Component Metadata Schemas
AI agents that orchestrate multiple components benefit from machine-readable metadata they can inspect programmatically, separate from the component implementation. The pattern is to export a meta object alongside each component, typed against a shared interface that describes the input schema, default duration, and any composition constraints.
// component-meta.ts
import type { ZodType } from 'zod'
import type { Frames } from './temporal-units'
export interface ComponentMeta<P extends object> {
/** Human-readable label shown in agent tooling. */
label: string
/** Default duration in frames when the caller does not specify. */
defaultDurationInFrames: Frames
/** Zod schema for runtime validation of generated props. */
propsSchema: ZodType<P>
}
// SlideIn.meta.ts
import { z } from 'zod'
import { frames } from './temporal-units'
import type { ComponentMeta } from './component-meta'
import type { SlideInProps } from './SlideIn'
export const SlideInMeta: ComponentMeta<SlideInProps> = {
label: 'SlideIn — spring slide entrance from one of four edges',
defaultDurationInFrames: frames(90),
propsSchema: z.object({
startFrame: z.number().int().min(0),
from: z.enum(['left', 'right', 'top', 'bottom']),
}),
}
The Zod schema does double duty: it validates props at runtime when an agent-generated configuration first executes, and it is readable enough for an agent to inspect directly and understand what values are legal. A validation failure at startup is far preferable to a silent render with out-of-range values.
An Agent-Facing Component Registry
When a library grows beyond a handful of components, a flat registry file lets an agent enumerate all available components without crawling the source tree:
// registry.ts
import { FadeIn } from './FadeIn'
import { FadeInMeta } from './FadeIn.meta'
import { SlideIn } from './SlideIn'
import { SlideInMeta } from './SlideIn.meta'
import { SpringScale } from './SpringScale'
import { SpringScaleMeta } from './SpringScale.meta'
export const registry = [
{ component: FadeIn, meta: FadeInMeta },
{ component: SlideIn, meta: SlideInMeta },
{ component: SpringScale, meta: SpringScaleMeta },
] as const
An agent asked to “pick the right entrance animation for a 3-second slide” can read registry.ts, filter by defaultDurationInFrames, check the labels, and select a component without reading each component’s full source. The registry is a stable, predictable surface; the implementation files become an implementation detail.
Temporal Assertions in Tests
Type-level contracts catch structural errors; test-level assertions catch temporal errors. Remotion’s @remotion/renderer package exposes renderStill, which renders a single frame to a buffer without launching a preview UI. Asserting on specific frame outputs in a test suite gives AI-generated compositions a hard check.
The most valuable frames to assert on are boundary frames: frame 0 (an entrance should be fully off-screen or at zero opacity), the first frame past the animation start (stiffness should produce a non-zero progress value), and a frame well past durationInFrames (extrapolation should be clamped). An opacity fade from 0 to 1 over 30 frames should read exactly 0.0 at frame 0 and exactly 1.0 at frame 30, not 1.1 if extrapolateRight was accidentally left at the default 'extend'.
Rather than pixel-diffing a rendered image, drive assertions through a test composition that writes animated values into a data attribute:
// FadeIn.test-composition.tsx
import { useCurrentFrame, interpolate } from 'remotion'
import type { Frames } from './temporal-units'
export const FadeInTestComposition: React.FC<{ durationInFrames: Frames }> = ({
durationInFrames,
}) => {
const frame = useCurrentFrame()
const opacity = interpolate(frame, [0, durationInFrames], [0, 1], {
extrapolateRight: 'clamp',
})
return (
<div
data-testid="fade-root"
data-opacity={opacity.toFixed(4)}
style={{ opacity, width: '100%', height: '100%' }}
/>
)
}
A test renders specific frames via renderStill, parses the data-opacity attribute from the output HTML, and asserts numeric range. This test runs in CI and fails the moment an agent-generated change shifts the interpolation range unexpectedly, before the broken frame ever reaches a preview or a deployed render.
Wrapping Up
The central problem is that AI agents derive an animated component’s behavior from its static representation: types, JSDoc, and usage examples. Branded types for frames vs. seconds prevent silent unit errors that compile correctly but render wrong. JSDoc @param notes with concrete frame counts and stiffness ranges give agents empirical measurements they could not derive by reading the Remotion source. Prop surface minimization limits the guessing surface at each call site. Exported Zod-backed metadata schemas give an agent a machine-readable contract it can inspect and validate against without parsing TypeScript ASTs. Frame-specific test assertions catch the temporal errors that compile-time types cannot see.
These patterns also benefit human developers, but they are non-negotiable for agent reliability. Templates like the ones in the RenderComp catalog follow this structure so that any agent-assisted workflow, whether assembling a composition from library components, threading <Sequence> offsets, or adjusting spring parameters to match a timing brief, starts from a type-safe, annotated baseline rather than inference and guesswork.
The determinism that makes Remotion powerful for programmatic video generation is only as useful as the contracts your components publish. Make those contracts explicit, and an agent can assemble correct animations on the first attempt instead of the third.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →