Video Studio
GitHub

Studio / Components

Component catalogue

Generated from studio/src/lib: 14 components and 29 helpers. Every component is frame-local (frame 0 is its parent Sequence start) and takes delay, and most take durationInFrames.

On this page

Layout, background and camera

<Camera />

studio/src/lib/Camera.tsx

Plain camera rig: translate, then scale a world. Every number comes from the caller, so pair it with speedCurve (position from a speed table) and logZoom (zoom in log space).

const cam = speedCurve(table, { fps, distance });
<Camera x={cam.position(frame)} zoom={logZoom(p, 1, 1.5)}>...</Camera>
6 props, 1 required
PropTypeDefaultWhat it does
children requiredReact.ReactNodenone
xnumber0World offset in px: positive x moves the world LEFT (the camera pans right).
ynumber0
zoomnumber1
originXnumbernoneZoom origin in px from the top-left of the frame. Default: the centre.
originYnumbernone

<SafeArea />

studio/src/lib/SafeArea.tsx

Full-frame layout box padded by the safe margins of the current format.

Use it for layout box

<SafeArea justify="center" align="flex-start" gap={space.xl}>...</SafeArea>
5 props, 1 required
PropTypeDefaultWhat it does
children requiredReact.ReactNodenone
justify"flex-start" | "center" | "flex-end" | "space-between""center"Vertical placement inside the safe area.
align"flex-start" | "center" | "flex-end" | "stretch""flex-start"Horizontal placement inside the safe area.
gapnumber0
styleReact.CSSPropertiesnone

<Background />

studio/src/lib/Background.tsx

Scene background. Deterministic: the grid drift is a function of the frame, the grain pattern changes every 2 frames using Remotion's seeded random().

Use it for every scene

<Background variant="grid" cell={80} grain grainAmount={0.06} drift={4} seed="intro" />
9 props
PropTypeDefaultWhat it does
variant"plain" | "grid" | "dots""grid"plain = flat colour, grid = hairline grid, dots = dot grid.
cellnumber80Grid cell size in px.
grainbooleantrueFilm grain on top. Keep it subtle; it is texture, not an effect.
grainAmountnumber0.06Grain opacity, 0..1.
driftnumber4Slow constant drift of the grid in px per second (0 = static).
vignettebooleantrueDarken the edges to pull focus to the centre.
colorstringpalette.bg
lineColorstringpalette.line
seedstring"bg"Seed for the grain, so two backgrounds in one video do not look identical.

Text and copy

<FragmentChain />

studio/src/lib/FragmentChain.tsx

Film copy as fragments, not sentences (F-A2). Recipes: the animation is the verb (words before and after the demo, never describing it), stem plus rotating slot instead of a list. One accent per frame: only the newest fragment is accented.

from technique F-A2

<FragmentChain fragments={["Move once.", "Land softly.", "Cut."]} mode="build" step="claim" />
7 props, 1 required
PropTypeDefaultWhat it does
fragments requiredstring[]none1 to 4 words each. Check the budget with lintCopy (copy.ts).
mode"build" | "replace""build"build: fragments append to one held line, the newest in the accent, and the line re-centres once per fragment. replace: each fragment leaves whole and the next takes the same centre.
framesPerFragmentnumber30Frames between the start of one fragment and the next (the picture beat lives here).
delaynumber0
stepFrameStep"claim"Rung of the vw ramp (tokens.frameRamp). Build mode needs one line: use claim or smaller.
holdLastnumbernoneFrames the last fragment stays before the whole line leaves. Omit to stay until the scene ends.
accentColorstringpalette.accent

<GhostType />

studio/src/lib/GhostType.tsx

Typewriter text with a locked layout (F-C14). The FULL text is always laid out in the same inline flow; characters not typed yet are just transparent. Lines can never reflow and the box never changes height while typing. No cursor on purpose (see DESIGN_SYSTEM.md).

from technique F-C14

<GhostType text="Same frame, same pixels." fontSize={144} delay={20} maxWidth={1000} />
12 props, 2 required
PropTypeDefaultWhat it does
text requiredstringnone
fontSize requirednumbernoneFont size in px. For hero text pass useFrameType("claim").fontSize.
lineHeightnumber1.1
letterSpacingstring"-0.03em"
fontWeightnumber600
monobooleanfalse
delaynumber0
framesPerCharnumber1.6Frames per character.
commaPausenumber6Extra frames of pause after , ; : and after . ? !.
sentencePausenumber10
maxWidthnumbernone
colorstringpalette.text

<TitleReveal />

studio/src/lib/TitleReveal.tsx

Masked, line-by-line title with optional eyebrow and subtitle. The default opener.

Use it for openers, chapter titles

<TitleReveal eyebrow="Chapter one" title={"A small motion system,\nbuilt to be reused."} subtitle="Supporting line." size="display" delay={6} />
11 props, 1 required
PropTypeDefaultWhat it does
title requiredstringnoneTitle text. Use "\n" for intentional line breaks; each line is revealed through its own mask.
subtitlestringnone
eyebrowstringnoneSmall mono label above the title.
sizeExtract<TypeToken, "display" | "h1" | "h2">"display"
align"left" | "center""left"
direction"up" | "down""up"Lines rise from below (up) or drop from above (down).
delaynumber0
durationInFramesnumbernoneTotal time on screen including the exit. Omit to hold until the Sequence ends.
colorstringpalette.text
accentColorstringpalette.accent
maxWidthnumbernone

<WordStagger />

studio/src/lib/WordStagger.tsx

Word-by-word kinetic sentence. Use for statements and hooks.

Use it for statements, hooks, supporting lines

<WordStagger text="Motion should explain, not decorate." highlight={["explain"]} size="display" delay={4} />
14 props, 1 required
PropTypeDefaultWhat it does
text requiredstringnone
highlightstring[][]Words (case-insensitive, punctuation ignored) drawn in the accent colour. Use one, at most two.
sizeTypeToken"h1"
fontWeightnumbernone
monobooleanfalseSet in JetBrains Mono (labels, paths, code).
directionDirection"up"
delaynumber0
staggernumbertiming.wordStaggerFrames between words.
springSpringTokennone
durationInFramesnumbernone
colorstringpalette.text
accentColorstringpalette.accent
align"left" | "center""left"
maxWidthnumbernone

<CharStagger />

studio/src/lib/CharStagger.tsx

Per-character masked reveal. Characters are grouped per word so lines only ever break between words.

Use it for one short word or phrase

<CharStagger text="Made in code." size="display" align="center" delay={14} />
10 props, 1 required
PropTypeDefaultWhat it does
text requiredstringnoneKeep it short: a word or a short phrase. Long text per-character reads as noise.
sizeTypeToken"display"
fontWeightnumbernone
direction"up" | "down""up"Characters rise from a mask (up) or drop into it (down).
delaynumber0
staggernumbertiming.charStaggerFrames between characters. Fractions are fine.
springSpringTokennone
durationInFramesnumbernone
colorstringpalette.text
align"left" | "center""left"

<LowerThird />

studio/src/lib/LowerThird.tsx

Name and role tag anchored to the bottom safe margin. Sequence: accent bar grows, name wipes in, role fades up; reverse on exit.

Use it for naming a person or source

<LowerThird name="Sam Rivera" role="Product designer" delay={8} durationInFrames={60} />
7 props, 1 required
PropTypeDefaultWhat it does
name requiredstringnone
rolestringnoneRole, title or source line under the name.
side"left" | "right""left"
delaynumber0
durationInFramesnumber90Total time on screen including the exit. Lower thirds should always leave: default 3 s.
accentColorstringpalette.accent
panelbooleantrueDark backing panel for legibility over footage.

<Captions />

studio/src/lib/Captions.tsx

Word-timed captions, paged. Spoken words are full white, the current word is in the accent colour, upcoming words are dimmed.

Use it for word-timed subtitles

import words from "./captions.json";
<Captions captions={words} maxWordsPerPage={6} pauseMs={500} />
9 props, 1 required
PropTypeDefaultWhat it does
captions requiredCaptionWord[]noneWord-timed captions, e.g. from Whisper. A JSON file of {text,startMs,endMs}[] can be imported directly.
maxWordsPerPagenumber6Maximum words on screen at once. Keep it to one or two lines.
pauseMsnumber500A silence longer than this starts a new page.
lingerMsnumber400How long a page stays after its last word.
maxPageMsnumbernoneCut audio: a page never stays longer than this (see paginateCaptions).
breakOnSentencebooleannoneCut audio: start a new page after a word that ends a sentence.
offsetYnumber0Distance above the bottom safe margin.
maxWidthnumber1300
accentColorstringpalette.accent

Data

<StatCounter />

studio/src/lib/StatCounter.tsx

A single number that counts up with an ease-out, plus a label. Width is reserved so nothing jitters.

Use it for one number that matters

<StatCounter value={450} label="frames, every one rendered from code" delay={14} />
12 props, 1 required
PropTypeDefaultWhat it does
value requirednumbernone
fromnumber0
decimalsnumber0
prefixstring""
suffixstring""
labelstringnoneWhat the number means, in sentence case.
sizeExtract<TypeToken, "display" | "h1" | "h2">"display"
delaynumber0
countFramesnumbertiming.countFrames the count takes.
durationInFramesnumbernone
colorstringpalette.textNumber colour. Use the accent only when this is the scene's focal point.
align"left" | "center""left"

<BarChart />

studio/src/lib/BarChart.tsx

Horizontal bar chart. Bars grow in order with a spring; values ride on the bar ends.

Use it for comparing a handful of values

<BarChart title="Scene length, seconds" data={[{ label: "Title", value: 3.3 }]} highlight={0} max={5} decimals={1} valueSuffix=" s" />
13 props, 1 required
PropTypeDefaultWhat it does
data requiredBarDatum[]none
highlightnumbernoneIndex of the one bar that carries the accent. Others stay neutral.
maxnumbernoneScale maximum. Defaults to the largest value.
titlestringnone
valueSuffixstring""
decimalsnumber0
widthnumber900Overall width in px.
barHeightnumber44
labelWidthnumber200Width of the label column in px.
delaynumber0
staggernumbertiming.lineStaggerFrames between bars.
durationInFramesnumbernone
accentColorstringpalette.accent

Masks and transitions

<ShapeMask />

studio/src/lib/transitions.tsx

Reveals its children through an expanding rectangle or circle. Use for images, cards and end marks.

Use it for revealing an object inside a scene

<ShapeMask shape="circle" origin={[50, 50]} delay={10}><Logo /></ShapeMask>
9 props, 1 required
PropTypeDefaultWhat it does
children requiredReact.ReactNodenone
shape"rect" | "circle""rect"
fromWipeFrom"left"For rect: the side the reveal starts from.
origin[number, number][50, 50]For circle: centre in percent of the element.
delaynumber0
springSpringTokennone
revealFramesnumber24Frames the reveal takes.
reversebooleanfalsePlay backwards (hide) instead of reveal.
styleReact.CSSPropertiesnone

Sound

<Sfx />

studio/src/lib/Sfx.tsx

Places a sound effect at a frame. Keep cues next to the visual event they belong to.

Use it for sound effects

<Sfx at={sceneStart(1) - 4} src={cues.whoosh} volume={0.3} />
5 props, 2 required
PropTypeDefaultWhat it does
at requirednumbernoneFrame (relative to the parent Sequence) where the sound starts.
src requiredstringnone
volumenumber0.40..1. Cues sit under the voice and music: 0.2-0.5 is typical.
durationInFramesnumbernoneCut the cue after this many frames.
namestring"Sfx"

Motion and timing helpers

Pure functions of the frame: hooks, easing, camera curves, transitions, copy checks. Signatures are read from the source.

Motion hooks and curves

useInOut

Entrance and exit progress for an element, driven by the current frame only. enter goes 0 -> 1 with a spring. exit goes 0 -> 1 over the last exitFrames. visible = enter * (1 - exit), handy for opacity.

useInOut({ delay = 0, durationInFrames, spring: springName = "smooth", exitFrames = timing.exit, }: InOutOptions = {})
useStagger

Spring progress for one item in a staggered group.

useStagger()
useSafeArea

Safe-area insets and format for the current composition.

useSafeArea()
useFrameType

Frame-relative type style (vw ramp, text floor enforced) for the current composition width.

useFrameType(step: FrameStep)
directionVector

Unit vector for a direction: the element travels FROM the opposite side toward rest.

directionVector(direction: Direction): [number, number]
buoyant

"Buoyant" ease: a cubic ease-out plus a half-sine bob over the WHOLE lifetime, so a rising element keeps drifting through its peak instead of freezing. Returns 0 at t=0 and exactly 1 at t=1; in between it overshoots by about bob and settles back.

buoyant(bob = 0.03): EasingFn
entryDecay

Distance still to travel (px, always >= 0) for the INCOMING shot frame frames into an entry of entryFrames. Starts at speed and decays by decay per 60-fps frame; the offset is exactly 0 on the last frame. Apply it as a translate in the travel direction.

entryDecay( frame: number, entryFrames: number, { fps, speed = VELOCITY_CUT_DEFAULTS.speed, decay = VELOCITY_CUT_DEFAULTS.decay }: VelocityCutOptions, )
exitRamp

Distance travelled (px, always >= 0) by the OUTGOING shot frame frames into an exit of exitFrames. Speed grows by growth per 60-fps frame and is speed on the last frame, i.e. the fastest frame is the cut. Closed form of the geometric speed series.

exitRamp( frame: number, exitFrames: number, { fps, speed = VELOCITY_CUT_DEFAULTS.speed, growth = VELOCITY_CUT_DEFAULTS.growth }: VelocityCutOptions, )
exitOpacity

Exit opacity 1 -> 0 as 1 - (n/N)^1.5: reaches exactly 0 on the last frame (so a hard cut after it does not pop) and stays high early.

exitOpacity(frame: number, start: number, length: number)
softFade

Fade in that never shows an empty first frame: starts at floor (25 to 57 % is the range the source used) and reaches 1 after length frames.

softFade(frame: number, start: number, length: number, floor = 0.3)
logZoom

Zoom interpolated in log space, so equal steps feel like equal zoom changes and a 1x to 4x zoom does not rush at the end. t is progress 0..1 (apply any easing first). Never mix with a linear zoom on the same shot: it turns into a zoom-in/zoom-out wobble.

logZoom(t: number, from: number, to: number)
speedCurve

Camera driven by speed, not by position keys. The table gives speed as a share of cruise at times (0 at 0 s, 0.5 by 0.6 s, 1.0 by 1.6 s, held, easing to 0.12 at 8.3 s), the shares are smoothstepped between keys, integrated into position, and scaled so the camera ends exactly at distance. Because position is an integral of a continuous speed, it cannot surge or stop unless the table says so. The shape of the table IS the camera language of a glide shot.

speedCurve( table: readonly SpeedKey[], { fps, distance }: { fps: number; distance: number }, )
sampleCurve

Sample fn(frame) for frames startFrame .. startFrame + count - 1.

sampleCurve( fn: (frame: number) => number, { name, fps, unit = "value", startFrame = 0, count }: { name: string; fps: number; unit?: string; startFrame?: number; count: number; }, ): CurveFile
velocityOf

Per-frame velocity (difference of consecutive values; first entry repeats the second). Use it to find a zero-velocity run in the middle of a move.

velocityOf(values: readonly number[])
longestStall

Longest run of near-zero velocity strictly inside the move (ignores the first and last 10 % of frames). Returns its length in frames; > 2 usually reads as a stall.

longestStall(values: readonly number[], epsilon = 1e-4)

Text and copy

typingSchedule

Frame on which each character becomes visible (character i shows when frame >= result[i]).

typingSchedule( text: string, framesPerChar: number, commaPause: number, sentencePause: number, )

Copy checks

lintCopy

Returns warnings, never throws. An empty warnings array means the copy fits the budget.

lintCopy({ fragments, durationInFrames, fps, visibleFrames, coveredFrames, muted = false, }: LintInput): CopyLint
countWords
countWords(text: string)
COPY_LIMITS F-A2

Copy budget lint (F-A2). Pure, no imports, so scripts/copy-lint.mjs can load it.

{ wordsPer30s, mutedWordsPer30s, maxFragmentWords, maxWordsOnOneFrame, maxCoverage }

Captions

paginateCaptions F-C13

Caption pages. By default a page ends at maxWordsPerPage or at a pause longer than pauseMs. On CUT audio (joined takes, trimmed pauses) pauses vanish and one page can swallow dozens of words (F-C13), so also pass maxPageMs and breakOnSentence: a page then also ends at a word cap, a time cap and a sentence end, never by pause alone. Test with a pause-free input.

paginateCaptions( captions: readonly CaptionWord[], maxWordsPerPage: number, pauseMs: number, { maxPageMs = Infinity, breakOnSentence = false }: { maxPageMs?: number; breakOnSentence?: boolean; } = {}, ): Page[]

Transitions

maskWipe

Hard-edged directional wipe. The incoming scene is revealed from from; optional 3 px accent edge.

maskWipe( props: MaskWipeProps, ): TransitionPresentation<MaskWipeProps>
circleReveal

Incoming scene grows out of a circle. Use as a match cut: put the origin where the previous focal point was.

circleReveal( props: CircleRevealProps = { origin: [50, 50] }, ): TransitionPresentation<CircleRevealProps>
transitionTiming

Default timing for every scene transition: spring, no overshoot, timing.transitionFrames frames.

transitionTiming( durationInFrames: number = timing.transitionFrames, )
presets

The approved set. Pick from here instead of reaching for other presentations.

{ fade, wipe, circle, velocity, occluder }
velocityCut

Velocity-matched cut: the outgoing scene accelerates and is cut at its fastest frame, the incoming scene starts at the same speed and brakes to rest, so momentum crosses the edit. Pair with velocityCutTiming(frames) using the SAME frames. Delay the incoming scene's own entrances by cutAt * frames: it is hidden until then. Text in a moving scene should be under 20 px per frame by the time it is read.

velocityCut({ frames = 30, travel = "left", speed = 36, cutAt = 0.4, blur = true, }: Partial<VelocityCutProps> = {}): TransitionPresentation<VelocityCutProps>
velocityCutTiming

Linear timing for the velocity cut: its curves are closed forms of the frame, so no spring.

velocityCutTiming(frames = 30)
occluderCut

Hidden cut: a soft, neutral band sweeps from from across the frame and the hard cut happens at the midpoint, while the band covers the screen. Pair with occluderTiming(frames). Delay the incoming scene's entrances by half the length. Line-carry, impact slam and the light leak from the same source are not built (see the ledger).

occluderCut( props: Partial<OccluderCutProps> = {}, ): TransitionPresentation<OccluderCutProps>
occluderTiming
occluderTiming(frames = 24)

Sound

cues

Built-in cues, synthesised with ffmpeg by scripts/make-sfx.mjs (no licence concerns). Add your own files to public/sfx/ and pass src={staticFile("sfx/name.wav")}.

{ tick, whoosh, thud }