snapcn
23 components
DocsComponentsTemplatesVideo EditorShowcaseMarketplaceRoadmapChangelog

Getting started

IntroductionInstallationAgent skill
GitHub

Star snapcn

Free, open-source components.

Production-ready Remotion animations, transitions and backgrounds — you own the code.

Star on GitHubXFollow

MIT licensed · own your code.

© 2026 snapcn

DocsComponentsTemplatesVideo EditorShowcaseMarketplaceRoadmapChangelog
Getting startedIntroductionInstallationAgent skill

Karaoke Captions

Full-line captions with a progressive fill sweep across the spoken words and accent-colored keyword emphasis

Installation

pnpm
$ pnpm dlx shadcn@latest add @snapcn/karaoke-captions

Component code

The exact file shadcn add copies into your project.

Usage

The whole line stays on screen while a color fill sweeps left-to-right across the words to indicate speech progress — muted #667085 ramps to ink #101828 in the light theme, and a translucent-white ramp resolves to #FAFAFA in the dark theme. Emphasized words fill in the accent color and scale up subtly. Everything is driven by the current frame, so scrubbing and rendering are fully deterministic.

// src/Root.tsx
import { Composition } from "remotion";
import { KaraokeCaptions } from "@/components/snap-cn/karaoke-captions";

const CaptionScene = () => (
  <KaraokeCaptions
    lines={[
      {
        text: "Acme reconciles every transaction automatically",
        startFrame: 10,
        endFrame: 120,
        emphasize: [4], // "automatically" in accent blue
      },
    ]}
    mode="light"
    aspect="landscape"
    pill
  />
);

export const RemotionRoot = () => (
  <Composition
    id="KaraokeCaptions"
    component={CaptionScene}
    durationInFrames={150}
    fps={30}
    width={1280}
    height={720}
  />
);

Without lines, the component builds a single demo line from text and emphasize. For word-accurate karaoke timing (e.g. from a transcription API), pass wordTimings — the absolute frame each word starts filling; a word's fill ends when the next word begins, and the last word runs to endFrame:

<KaraokeCaptions
  lines={[
    {
      text: "Acme reconciles every transaction automatically",
      startFrame: 10,
      endFrame: 120,
      wordTimings: [10, 28, 52, 68, 92],
      emphasize: [4],
    },
  ]}
/>

The component renders with a transparent canvas (plus the optional pill surface), so place it over any footage or backdrop. Unlike word-by-word caption styles, the full line is always visible — the fill is what indicates progress.

Safe-area presets

The aspect prop positions the caption block clear of platform UI:

  • landscape — 16:9 videos; sits 8% from the bottom with 10% side margins.
  • portrait — 9:16 (TikTok / Reels / Shorts); raised to 18% from the bottom to clear platform chrome, 7% side margins.
  • square — 1:1 feeds; 10% from the bottom, 8% side margins.

Using a real transcript

Nobody has frame numbers. Your transcript is in milliseconds — the shape Whisper, CapCut and Remotion's own @remotion/captions all speak:

<KaraokeCaptions
  captions={[
    { text: "Acme",        startMs: 1000, endMs: 1400 },
    { text: "reconciles",  startMs: 1400, endMs: 2000 },
    { text: "every",       startMs: 2000, endMs: 2300 },
    { text: "transaction", startMs: 2300, endMs: 3000 },
  ]}
  preset="karaoke"
/>

or paste an .srt straight in with srt={...}. Word-level output is collapsed into LINES (consecutive words become one line, a pause starts a new one) — which is exactly how a caption tool builds a line. Milliseconds are converted against useVideoConfig().fps, so one transcript is correct at any frame rate.

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 stroke38px
-webkit-text-stroke alone22px — the stroke eats 42% of the letterform
+ paint-order: stroke fill38px — identical to no stroke
WebkitTextStrokeWidth: `${stroke}px`,
WebkitTextStrokeColor: "#000",
paintOrder: "stroke fill",   // draw the stroke, THEN the fill over it

That 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

PropTypeDefaultDescription
preset
"boxed" | "karaoke" | "highlight" | "clean""boxed"boxed (default): the YouTube auto-caption — white Roboto on a solid per-line black box, no outline. karaoke: heavy outlined line, each word snapping from dimmed to full white as it is spoken. highlight: the spoken word rides a rounded accent bar. clean: the quiet pill, no outline
strokeRatio
number—Outline width as a fraction of the font size. Defaults per preset. 0 turns it off
strokeColor
string"#000000"Outline colour
uppercase
boolean—Force upper case. Defaults per preset
lines
CaptionLine[]—Timed lines: { text, startFrame, endFrame, wordTimings?: number[], emphasize?: number[] }. Omit to build a single line from text
text
string"Acme reconciles every transaction automatically"Convenience single line used when lines is not provided
emphasize
string"automatically"Comma-separated words of text to emphasize (case-insensitive, punctuation ignored). Only used with the text fallback
mode
"light" | "dark""dark"Which end of the design system to resolve tokens against
theme
Partial<SnapCnTheme>—Design-system token overrides. Anything you leave out falls back to the shadcn defaults
accentColor
stringtheme.primaryFill color of emphasized words
fontSize
number48Font size in pixels
fontWeight
number600CSS font-weight
aspect
"landscape" | "portrait" | "square""landscape"Safe-area preset positioning the caption block
pill
booleantrueRounded surface behind the line with a hairline border
emphasisScale
number1.04Scale emphasized words grow to as their fill completes (ease-out, no bounce)
baseColor
string—Unfilled (not-yet-spoken) word color override
fillColor
string—Filled (spoken) word color override
speed
number1Global playback multiplier
className
string—Optional className passed to the outer wrapper