Introduction

Subtitles rendered from a timed transcript, word by word, in sync with a real voiceover

0:04.20
captions.json23 wordstextstartMsendMs
  1. "first"35203820
  2. "three"38204100
  3. "seconds."41004780
  4. "Captions"54005920
  5. "keep"59206140
  6. "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.

  1. Get a transcriptTranscribe once, outside the video, with whichever speech-to-text you already use.
  2. Save it as JSONEvery source ends in the same Caption[] array. Keep it next to your composition.
  3. Pick a styleEvery style shares the same base props, so switching looks is a one-line change.
Where the transcript comes fromEach ends in the same Caption[]
  • 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
Installation
$ pnpm dlx shadcn@latest add @remocn/caption-karaoke
subtitles.tsx
tsx
import captions from "./captions.json";
import { CaptionKaraoke } from "@/components/remocn/caption-karaoke";
 
export const Subtitles = () => <CaptionKaraoke captions={captions} />;

All caption styles

Every tile reads the same transcript and shows the word being spoken right now.

31
Filter by vibe

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

PropTypeDefault
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).

    Defaultper style

  • holdMsnumber

    A page leaves this long after its last word ends, or when the next page starts, whichever comes first.

    Default600

  • fontSizenumber

    Font size in composition pixels.

    Defaultper style

  • fontWeightnumber

    Font weight.

    Defaultper style

  • colorstring

    Text color.

    Defaultper 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.

tsx
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.

tsx
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.