Word Captions
TikTok-style word-by-word burned-in captions from a timed transcript
Installation
$ pnpm dlx shadcn@latest add @snapcn/word-captionsComponent code
The exact file shadcn add copies into your project.
Usage
Feed it a timed transcript — one { text, startFrame, endFrame? } per word, e.g.
mapped from Whisper word timestamps — and it burns captions into the frame: the
current beat sits in a rounded backdrop pill inside the safe area of your aspect,
and each word pops the moment it is spoken. The component has a transparent
background, so overlay it on your screen recording or any scene.
// src/Root.tsx
import { AbsoluteFill, Composition, OffthreadVideo, staticFile } from "remotion";
import { WordCaptions } from "@/components/snap-cn/word-captions";
const CaptionedClip = () => (
<AbsoluteFill>
<OffthreadVideo src={staticFile("screen-recording.mp4")} />
<WordCaptions
words={[
{ text: "Stop", startFrame: 0 },
{ text: "losing", startFrame: 14 },
{ text: "hours", startFrame: 28 },
{ text: "to", startFrame: 42 },
{ text: "manual", startFrame: 56 },
{ text: "invoices", startFrame: 70, endFrame: 84 },
]}
aspect="9:16"
activeStyle="pop"
/>
</AbsoluteFill>
);
export const RemotionRoot = () => (
<Composition
id="WordCaptions"
component={CaptionedClip}
durationInFrames={96}
fps={30}
width={1080}
height={1920}
/>
);A word's endFrame defaults to the next word's startFrame, so contiguous
transcripts only need start times. For quick drafts you can also pass words as a
plain string — it is split on whitespace and paced evenly at framesPerWord.
Set groupSize={2} or {3} to show short phrases per beat instead of single words.
Active-word styles
pop— the spoken word scales to 1.06 with a fast 6-frame high-damping spring (no bounce).highlight— the spoken word gets a rounded accent-colored chip behind it (CapCut-style).color— the spoken word's text switches to the accent color; the rest stays white.
Aspect presets
9:16— captions ride 20% up from the bottom, clear of feed UI (like/comment/share chrome).1:1— captions sit 11% up from the bottom edge.16:9— classic lower-third placement, 8% up from the bottom.
Using a real transcript
This is the part that matters. Nobody has frame numbers — you have a transcript, and
it is in milliseconds. Whisper, whisper.cpp, CapCut, Submagic, Opus Clip, Descript
and Remotion's own @remotion/captions all speak the same shape:
type Caption = { text: string; startMs: number; endMs: number };So that is what this takes. Drop your Whisper output straight in:
import { WordCaptions } from "@/components/snap-cn/word-captions";
// whatever your transcription tool gave you — word level or phrase level
const captions = [
{ text: "You", startMs: 1000, endMs: 1180 },
{ text: "are", startMs: 1180, endMs: 1320 },
{ text: "losing", startMs: 1320, endMs: 1700 },
{ text: "three", startMs: 1700, endMs: 2050 },
{ text: "hours", startMs: 2050, endMs: 2400 },
];
<WordCaptions captions={captions} preset="youtube" />;Or paste an .srt in as-is — cues get split into words for you, so the active-word
highlight still tracks:
<WordCaptions srt={srtFileContents} preset="youtube" />The frame maths is the component's problem, not yours: milliseconds are converted
against useVideoConfig().fps, so the same transcript is correct at 24, 30 or 60fps.
Pages, not chunks
Captions are grouped into pages the way real caption tools group them — by the
shape of the speech. A page ends when it runs out of words, runs out of its character
budget, or the speaker pauses (pageBreakMs). Chopping every N words regardless
is how you end up with STOP / LOSING / HOURS stacked in a tower: three words that
never belonged on a page together.
maxWords, maxChars and pageBreakMs override the preset if you need to.
What actually makes a caption look premium
Three things, and the first one is most of it.
1. An OUTSIDE outline
A caption has to stay legible over footage it has never seen — a face, a sky, a white desk. A heavy black outline is the only thing that does that, and it has to sit outside the letterform.
-webkit-text-stroke centres the stroke on the glyph outline, so half of it eats
inwards and the letters go thin and mushy. Measured on Montserrat 900 at 160px with a
14px stroke:
| white stem | |
|---|---|
| no stroke | 38px |
-webkit-text-stroke alone | 22px — the stroke eats 42% of the letterform |
+ paint-order: stroke fill | 38px — identical to no stroke |
WebkitTextStrokeWidth: `${stroke}px`,
WebkitTextStrokeColor: "#000",
paintOrder: "stroke fill", // draw the stroke, THEN the fill over itThat one line is most of the difference between a caption and a cheap caption.
2. Weight and size
Montserrat 800–900, at 11–13% of the frame's short side. Not Inter at 700, and not
2.8% of the height — that is a subtitle. Load the face through @remotion/google-fonts
so the Player, the mp4 and your own project all get the same one.
3. The spoken word lands
A spring with overshoot, not an ease. And that scale is a scale on text, which is where captions usually fall apart: a browser gives glyph origins no vertical sub-pixel precision, so a scale that moves the baseline makes the word climb the pixel grid in whole-pixel jumps. The pop pivots on the measured baseline — verified on rendered frames, the baseline holds to 0px while the word scales 1.16×, so it grows upward off its baseline exactly like real caption type.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
preset | "boxed" | "youtube" | "beast" | "hormozi" | "pop" | "clean" | "boxed" | boxed (default): the YouTube auto-caption — white Roboto on a solid per-line black box, no outline. beast: ALL CAPS Montserrat Black, heavy outline, spoken word snaps to yellow. hormozi: the spoken word lands on a filled accent block. pop: sentence case, outlined. clean: a quiet pill |
strokeRatio | number | — | Outline width as a fraction of the font size. Defaults per preset. This is what keeps a caption legible over any footage — 0 turns it off |
strokeColor | string | "#000000" | Outline colour |
uppercase | boolean | — | Force upper case. Defaults per preset — the loud ones shout |
accentCycle | string[] | — | Accent set the hormozi block cycles through, one colour per beat |
words | { text: string; startFrame: number; endFrame?: number }[] | string | "Stop losing hours to manual invoices" | Timed transcript. A missing endFrame defaults to the next word's startFrame (the last word holds for framesPerWord). A plain string is split on whitespace and paced evenly at framesPerWord |
groupSize | number | 1 | Tokens shown per caption beat, clamped to 1–3 |
activeStyle | "pop" | "highlight" | "color" | "pop" | How the currently-spoken word is emphasized: scale pop, accent chip, or accent text color |
aspect | "16:9" | "1:1" | "9:16" | "16:9" | Safe-area position preset — controls how far the caption block sits above the bottom edge |
maxWidth | number | 800 | Max width of the caption block in px; longer beats wrap and stay centered |
fontSize | number | 54 | Caption font size in pixels |
textColor | string | theme.card | Caption text color |
pillColor | string | theme.background @ 55% | Backdrop pill behind the caption line (rounded 8px). Any CSS color; empty string removes the pill |
accentColor | string | theme.primary | Active-word accent used by the highlight and color styles |
framesPerWord | number | 14 | Even pacing used when words is a plain string; also the hold time of the final word |
fontWeight | number | 700 | CSS font-weight of the caption text |
speed | number | 1 | Global playback multiplier applied to the whole transcript |
className | string | — | Optional className passed to the outer wrapper |
theme | Partial<SnapCnTheme> | — | Design-system token overrides. Anything you leave out falls back to the shadcn defaults |
mode | "light" | "dark" | — | Which end of the design system to resolve tokens against |