reelcn
Social

Social

Captions

Word-synced captions that page like TikTok, highlight the spoken word and pop the words you choose.

Install

Usage

import captions from "../../public/captions/talk.json";

<Captions captions={captions} emphasize={["free", "today"]} variant="bold-pop" />

Props

NameTypeDefaultDescription
captionsCaption[]Word-level captions, as `@remotion/captions` defines them. `pnpm reelcn-transcribe` writes this shape.
pageMs?number1200How much speech goes on one page, in milliseconds. Lower means fewer words at a time.
maxWords?number6Hard cap on words per page; long pages are split.
emphasize?string[][]Words painted in the emphasis color. Case and punctuation are ignored.
variant?CaptionsVariant"bold-pop"
position?"auto" | "top" | "bottom" | "middle""auto"`auto` sits above the platform UI in portrait and in the lower third elsewhere.
size?number64Font size in design units; long pages shrink from here.
color?string
activeColor?string
emphasisColor?string
background?string
style?CSSProperties
className?string

Use

  • Shorts, reels and talking-head videos that need burned-in captions
  • Podcast and tutorial clips watched without sound

Avoid

Dependencies

@remotion/captions, remotion

Source

registry/items/captions.tsx
/**
 * @title Captions
 * @category social
 * @description Word-synced captions that page like TikTok, highlight the spoken word and pop the words you choose.
 * @duration data-driven
 * @use Shorts, reels and talking-head videos that need burned-in captions
 * @use Podcast and tutorial clips watched without sound
 * @avoid A single static line of text — use `text-reveal`
 * @tags captions, subtitles, karaoke, shorts, accessibility
 * @example
 * import captions from "../../public/captions/talk.json";
 *
 * <Captions captions={captions} emphasize={["free", "today"]} variant="bold-pop" />
 */
import { type Caption, createTikTokStyleCaptions } from "@remotion/captions";
import type React from "react";
import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "remotion";
import { alpha, tween, useTheme, useViewport } from "./core";

export type CaptionsVariant =
  | "bold-pop"
  | "karaoke"
  | "boxed"
  | "minimal"
  | "neon"
  | "word-stack"
  | "subtitle-bar"
  | "highlight-box";

export type CaptionsProps = {
  /** Word-level captions, as `@remotion/captions` defines them. `pnpm reelcn-transcribe` writes this shape. */
  captions: Caption[];
  /** How much speech goes on one page, in milliseconds. Lower means fewer words at a time. */
  pageMs?: number;
  /** Hard cap on words per page; long pages are split. */
  maxWords?: number;
  /** Words painted in the emphasis color. Case and punctuation are ignored. */
  emphasize?: string[];
  variant?: CaptionsVariant;
  /** `auto` sits above the platform UI in portrait and in the lower third elsewhere. */
  position?: "auto" | "top" | "middle" | "bottom";
  /** Font size in design units; long pages shrink from here. */
  size?: number;
  color?: string;
  activeColor?: string;
  emphasisColor?: string;
  background?: string;
  style?: React.CSSProperties;
  className?: string;
};

type Token = { text: string; fromMs: number; toMs: number };
type Page = { tokens: Token[]; startMs: number; endMs: number };

const normalize = (word: string) => word.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");

/** Pages from @remotion/captions, split again so no page exceeds `maxWords`. */
function paginate(captions: Caption[], pageMs: number, maxWords: number): Page[] {
  const { pages } = createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: pageMs });
  const out: Page[] = [];
  for (const page of pages) {
    for (let index = 0; index < page.tokens.length; index += maxWords) {
      const tokens = page.tokens.slice(index, index + maxWords);
      if (tokens.length === 0) continue;
      out.push({ tokens, startMs: tokens[0].fromMs, endMs: tokens[tokens.length - 1].toMs });
    }
  }
  return out;
}

export function Captions({
  captions,
  pageMs = 1200,
  maxWords = 6,
  emphasize = [],
  variant = "bold-pop",
  position = "auto",
  size = 64,
  color,
  activeColor,
  emphasisColor,
  background,
  style,
  className,
}: CaptionsProps) {
  const { fps } = useVideoConfig();
  const { u, height, safe, isPortrait } = useViewport();
  const theme = useTheme();
  const pages = paginate(captions, pageMs, maxWords);
  const emphasized: Record<string, true> = {};
  for (const word of emphasize) emphasized[normalize(word)] = true;

  const place = position === "auto" ? (isPortrait ? "bottom" : "lower") : position;
  const box: React.CSSProperties =
    place === "top"
      ? { top: safe.top, justifyContent: "flex-start", alignItems: "center" }
      : place === "middle"
        ? { top: 0, bottom: 0, justifyContent: "center", alignItems: "center" }
        : place === "bottom"
          ? // AbsoluteFill already sets top: 0 and height: 100%; without unsetting top, "bottom" is
            // dropped as over-constrained and captions render flush against the frame edge.
            { top: "auto", bottom: safe.bottom + u(40), justifyContent: "flex-end", alignItems: "center" }
          : { top: "auto", bottom: height * 0.14, justifyContent: "flex-end", alignItems: "center" };

  return (
    <AbsoluteFill className={className} style={{ justifyContent: "center", ...style }}>
      {pages.map((page, index) => {
        const next = pages[index + 1];
        const endMs = Math.min(next ? next.startMs : page.endMs + 320, page.endMs + 320);
        const from = Math.round((page.startMs / 1000) * fps);
        const durationInFrames = Math.max(1, Math.round((endMs / 1000) * fps) - from);
        return (
          <Sequence key={page.startMs} from={from} durationInFrames={durationInFrames} premountFor={fps}>
            <AbsoluteFill style={{ display: "flex", justifyContent: "center", ...box, position: "absolute" }}>
              <CaptionPage
                page={page}
                variant={variant}
                emphasized={emphasized}
                size={size}
                color={color ?? theme.colors.foreground}
                activeColor={activeColor ?? theme.colors.accent}
                emphasisColor={emphasisColor ?? theme.colors.highlight}
                background={background ?? theme.colors.surface}
              />
            </AbsoluteFill>
          </Sequence>
        );
      })}
    </AbsoluteFill>
  );
}

function CaptionPage({
  page,
  variant,
  emphasized,
  size,
  color,
  activeColor,
  emphasisColor,
  background,
}: {
  page: Page;
  variant: CaptionsVariant;
  emphasized: Record<string, true>;
  size: number;
  color: string;
  activeColor: string;
  emphasisColor: string;
  background: string;
}) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const { u, width, safe } = useViewport();
  const theme = useTheme();
  const nowMs = page.startMs + (frame / fps) * 1000;
  const characters = page.tokens.reduce((total, token) => total + token.text.length, 0);
  // Long pages shrink so a caption never runs past the safe area.
  const fontPx = u(size) * Math.min(1, 26 / Math.max(26, characters));
  const enter = tween(frame, fps, { duration: Math.round(fps * 0.18), motion: "snappy" });
  const stack = variant === "word-stack";

  const words = page.tokens.map((token, index) => {
    const active = token.fromMs <= nowMs && nowMs < token.toMs;
    const spoken = nowMs >= token.toMs;
    const isEmphasis = emphasized[normalize(token.text)] === true;
    const base: React.CSSProperties = { display: "inline-block", whiteSpace: "pre" };
    const tone = isEmphasis ? emphasisColor : active ? activeColor : color;

    if (variant === "karaoke") {
      return (
        <span key={index} style={{ ...base, color: spoken || active ? tone : alpha(color, 0.45) }}>
          {token.text}
        </span>
      );
    }
    if (variant === "boxed") {
      return (
        <span
          key={index}
          style={{
            ...base,
            padding: `${u(6)}px ${u(14)}px`,
            margin: u(4),
            borderRadius: u(10),
            background: active ? tone : alpha(background, 0.92),
            color: active ? theme.colors.accentForeground : isEmphasis ? emphasisColor : color,
          }}
        >
          {token.text.trim()}
        </span>
      );
    }
    if (variant === "highlight-box") {
      return (
        <span
          key={index}
          style={{
            ...base,
            color: active ? theme.colors.background : tone,
            background: active ? emphasisColor : "transparent",
            padding: `0 ${u(6)}px`,
            borderRadius: u(4),
          }}
        >
          {token.text}
        </span>
      );
    }
    if (variant === "neon") {
      return (
        <span
          key={index}
          style={{
            ...base,
            color: active || isEmphasis ? tone : color,
            // The glow is this variant's whole point.
            textShadow: active ? `0 0 ${u(18)}px ${alpha(tone, 0.9)}, 0 0 ${u(44)}px ${alpha(tone, 0.55)}` : "none",
          }}
        >
          {token.text}
        </span>
      );
    }
    if (variant === "minimal" || variant === "subtitle-bar") {
      return (
        <span key={index} style={{ ...base, color: active || isEmphasis ? tone : color }}>
          {token.text}
        </span>
      );
    }
    // bold-pop and word-stack: the spoken word grows.
    return (
      <span
        key={index}
        style={{
          ...base,
          color: tone,
          scale: active ? String(1.08) : "1",
          translate: stack && active ? `0 ${-u(4)}px` : undefined,
        }}
      >
        {token.text}
      </span>
    );
  });

  const shared: React.CSSProperties = {
    maxWidth: width - safe.x * 2,
    textAlign: "center",
    fontFamily: theme.fonts.heading,
    fontWeight: variant === "minimal" || variant === "subtitle-bar" ? 600 : theme.headingWeight,
    fontSize: fontPx,
    lineHeight: 1.12,
    letterSpacing: "-0.02em",
    opacity: Math.min(enter, 1),
    translate: `0 ${(1 - Math.min(enter, 1)) * u(14)}px`,
  };

  if (variant === "subtitle-bar") {
    return (
      <div
        style={{
          ...shared,
          background: alpha(background, 0.82),
          padding: `${u(12)}px ${u(24)}px`,
          borderRadius: u(8),
        }}
      >
        {words}
      </div>
    );
  }

  if (stack) {
    return (
      <div style={{ ...shared, display: "flex", flexDirection: "column", alignItems: "center", gap: u(2) }}>
        {words}
      </div>
    );
  }

  return (
    <div
      style={{
        ...shared,
        textShadow: variant === "bold-pop" ? `0 ${u(4)}px ${u(18)}px ${alpha("#000000", 0.45)}` : undefined,
      }}
    >
      {words}
    </div>
  );
}

Related