Introduction
Subtitles rendered from a timed transcript, word by word, in sync with a real voiceover
0:04.20- "first"35203820
- "three"38204100
- "seconds."41004780
- "Captions"54005920
- "keep"59206140
- "them"61406300
How it works
A caption takes a transcript with a start and end time on every word and turns it into a text block that follows the voice. It groups words into short pages, shows one page at a time and animates the word being spoken.
- Get a transcriptTranscribe once, outside the video, with whichever speech-to-text you already use.
- Save it as JSONEvery source ends in the same Caption[] array. Keep it next to your composition.
- Pick a styleEvery style shares the same base props, so switching looks is a one-line change.
- whisper.cpp, locallyFree and offline. Transcribes a 16 kHz WAV file.
@remotion/install-whisper-cpp - OpenAI Whisper APIRequest verbose JSON with word timestamps.
@remotion/openai-whisper - ElevenLabsThe Speech-to-Text response or a segmented export.
@remotion/elevenlabs - Existing subtitlesParse an .srt file. Lines are timed, not single words.
parseSrt()
Install the caption runtime first, so it matches your Remotion version:
npx remotion add @remotion/captions$ pnpm dlx shadcn@latest add @remocn/caption-karaokeimport captions from "./captions.json";
import { CaptionKaraoke } from "@/components/remocn/caption-karaoke";
export const Subtitles = () => <CaptionKaraoke captions={captions} />;
Karaokesocial
Highlight Boxsocial
Active Popsocial
Underlineclean
Dim Progressclean
Weightpremium
Outlinesocial
Prosodyplayful
Word Popplayful
Bounce Inplayful
Blur Inpremium
Riseclean
Slicetech
Dropplayful
Typewritertech
Handwritepaper
Scrambletech
Split Flaptech
Label Makerpaper
Redacttech
Knockoutpremium
Slotplayful
Cubeplayful
Stackplayful
Subtitleclean
Teleprompterclean
Timelinetech
Markerpaper
Stampplayful
Emojiplayful
Speakersocial
Captions are not typography
Word Stream, Typewriter and Per-Word Crossfade animate text you script yourself, on timing they invent. A caption has no timing of its own: every word lands when the speaker says it, because the transcript says so. That is what length: transcript-driven means in the docs metadata.
Reach for typography when the words are on-screen copy. Reach for a caption when the words are someone talking.
The shared API
Every caption-* component accepts the same base props, so switching styles is a one-line change.
Base props7
| Prop | Type | Default |
|---|---|---|
| captions | Caption[]— Timed words from @remotion/captions, each one { text, startMs, endMs, timestampMs, confidence }. | |
| combineTokensWithinMilliseconds | numberper style How much speech one page holds. Higher values put more words on a page. A page also ends at the end of a sentence and on any pause of at least holdMs (300 ms minimum). | |
| holdMs | number600 A page leaves this long after its last word ends, or when the next page starts, whichever comes first. | |
| fontSize | numberper style Font size in composition pixels. | |
| fontWeight | numberper style Font weight. | |
| color | stringper style Text color. | |
| className | string— Classes on the text block. | |
- captionsCaption[]
Timed words from @remotion/captions, each one { text, startMs, endMs, timestampMs, confidence }.
Default
— - combineTokensWithinMillisecondsnumber
How much speech one page holds. Higher values put more words on a page. A page also ends at the end of a sentence and on any pause of at least holdMs (300 ms minimum).
Default
per style - holdMsnumber
A page leaves this long after its last word ends, or when the next page starts, whichever comes first.
Default
600 - fontSizenumber
Font size in composition pixels.
Default
per style - fontWeightnumber
Font weight.
Default
per style - colorstring
Text color.
Default
per style - classNamestring
Classes on the text block.
Default
—
On top of these, each style adds its own props, such as the color of the active word.
Get a transcript shows how to produce captions from an audio file.
Composition length
A caption runs as long as the transcript does. Size the composition from the last word with calculateMetadata, plus the hold, so the video never cuts the final page short.
import { Composition } from "remotion";
import captions from "./captions.json";
import { Subtitles } from "./subtitles";
const FPS = 30;
const HOLD_MS = 600;
export const RemotionRoot = () => (
<Composition
id="Subtitles"
component={Subtitles}
fps={FPS}
width={1080}
height={1920}
durationInFrames={1}
calculateMetadata={() => ({
durationInFrames: Math.ceil(
((captions[captions.length - 1].endMs + HOLD_MS) / 1000) * FPS,
),
})}
/>
);Placement
A caption renders only its text block. It does not position itself in the frame, so you decide where the block sits. Positioning has the safe zones for landscape and vertical video.
Fonts and casing
A caption inherits its font, so it matches the rest of your video. Load a typeface with @remotion/google-fonts and set it on a wrapper, or pass a font class through className.
import { loadFont } from "@remotion/google-fonts/Montserrat";
const { fontFamily } = loadFont("normal", { weights: ["800"] });
export const Subtitles = () => (
<div style={{ fontFamily }}>
<CaptionKaraoke captions={captions} />
</div>
);Text keeps the casing of the transcript. Add className="uppercase" when a style reads better in caps.