Cutship
Sign inGet started
← All edits
cutship.ai

Component · Captions · 9:16

Word Pop Captions

by CutshipVerified

Word-by-word captions synced to a timestamp array. The active word pops up and switches color — the classic talking-head caption style.

1 taken · 1 views

Free

Sign up free to Make this

Sign up free to unlock the spec, agent prompt and Make this.

components/remotion/word-pop-captions/component.tsx

import React, { useEffect, useMemo, useState } from "react";
import {
  AbsoluteFill,
  Sequence,
  continueRender,
  delayRender,
  spring,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { Audio } from "@remotion/media";
import { loadFont } from "@remotion/google-fonts/Montserrat";
import { getAvailableFonts } from "@remotion/google-fonts";
import type { CaptionWord, WordPopCaptionsProps } from "./schema";

const { fontFamily: defaultFont } = loadFont("normal", { weights: ["900"], subsets: ["latin"] });

const resolveSrc = (src: string) => (/^(https?:|data:|blob:|\/)/.test(src) ? src : staticFile(src));
const POSITION_Y = { top: 0.3, middle: 0.5, bottom: 0.66 } as const;

/** Loads a brand-kit Google Font by family name; returns it once ready. */
const useBrandFont = (family?: string) => {
  const [ready, setReady] = useState<string | null>(null);
  useEffect(() => {
    if (!family) return setReady(null);
    const entry = getAvailableFonts().find((f) => f.fontFamily.toLowerCase() === family.toLowerCase());
    if (!entry) return;
    const handle = delayRender(`Loading font ${family}`);
    entry
      .load()
      .then((mod) => {
        const weights = Object.keys(mod.getInfo().fonts.normal ?? {});
        const heaviest = ["900", "800", "700", "600"].find((w) => weights.includes(w));
        return mod.loadFont("normal", { subsets: ["latin"], weights: heaviest ? [heaviest] : undefined }).waitUntilDone();
      })
      .then(() => setReady(entry.fontFamily))
      .catch(() => setReady(null))
      .finally(() => continueRender(handle));
  }, [family]);
  return ready;
};


type Page = { words: CaptionWord[]; startMs: number; endMs: number };

const toPages = (words: CaptionWord[], perPage: number, pauseBreakMs: number): Page[] => {
  const pages: Page[] = [];
  let current: CaptionWord[] = [];
  const flush = () => {
    if (current.length === 0) return;
    pages.push({
      words: current,
      startMs: current[0].startMs,
      endMs: current[current.length - 1].endMs,
    });
    current = [];
  };
  words.forEach((word, i) => {
    const prev = words[i - 1];
    const gap = prev ? word.startMs - prev.endMs : 0;
    if (current.length >= perPage || (current.length > 0 && gap >= pauseBreakMs)) flush();
    current.push(word);
    // Sentence-ending punctuation closes the line.
    if (/[.!?:]$/.test(word.text)) flush();
  });
  flush();
  return pages;
};

export const WordPopCaptions: React.FC<WordPopCaptionsProps> = ({
  words,
  wordsPerPage,
  pauseBreakMs,
  fontSize,
  textColor,
  activeColor: baseActiveColor,
  strokeColor,
  strokeWidth,
  activeScale,
  uppercase,
  positionY: basePositionY,
  tickSound,
  tickSrc,
  tickVolume,
  brandKit,
}) => {
  const frame = useCurrentFrame();
  const { fps, width, height } = useVideoConfig();
  const nowMs = (frame / fps) * 1000;
  const brandFont = useBrandFont(brandKit?.fonts[0]);
  const fontFamily = brandFont ?? defaultFont;
  const activeColor = brandKit?.colors[0] ?? baseActiveColor;
  const positionY = brandKit?.captionPosition ? POSITION_Y[brandKit.captionPosition] : basePositionY;

  const pages = useMemo(
    () => toPages(words, wordsPerPage, pauseBreakMs),
    [words, wordsPerPage, pauseBreakMs],
  );

  // A page stays up until the next one starts (or 400ms after its last word).
  const page = pages.find((p, i) => {
    const next = pages[i + 1];
    const until = next ? Math.min(next.startMs, p.endMs + 400) : p.endMs + 400;
    return nowMs >= p.startMs && nowMs < until;
  });
  // One tick per word, placed on the word's exact start frame.
  const ticks =
    tickSound && tickSrc
      ? words.map((w, i) => (
          <Sequence key={i} from={Math.round((w.startMs / 1000) * fps)} durationInFrames={Math.ceil(fps * 0.2)} layout="none" name={`tick ${w.text}`}>
            <Audio src={resolveSrc(tickSrc)} volume={tickVolume} />
          </Sequence>
        ))
      : null;
  if (!page) return <>{ticks}</>;

  const pageFrame = frame - Math.round((page.startMs / 1000) * fps);
  const pageIn = spring({ frame: pageFrame, fps, config: { damping: 14, stiffness: 220 } });

  let activeIndex = -1;
  page.words.forEach((w, i) => {
    if (nowMs >= w.startMs) activeIndex = i;
  });

  // TikTok/Reels UI: keep clear of the right action rail and side edges.
  const sidePadding = width * 0.07;

  return (
    <AbsoluteFill style={{ pointerEvents: "none" }}>
      {ticks}
      <div
        style={{
          position: "absolute",
          left: sidePadding,
          right: sidePadding * 1.6,
          top: height * positionY,
          transform: `translateY(-50%) scale(${0.85 + 0.15 * pageIn})`,
          opacity: Math.min(1, pageIn * 1.5),
          display: "flex",
          flexWrap: "wrap",
          justifyContent: "center",
          alignItems: "center",
          columnGap: fontSize * 0.28,
          rowGap: fontSize * 0.1,
          fontFamily,
          fontWeight: 900,
          fontSize,
          lineHeight: 1.1,
          textAlign: "center",
          textTransform: uppercase ? "uppercase" : "none",
          letterSpacing: "-0.01em",
        }}
      >
        {page.words.map((word, i) => {
          const isActive = i === activeIndex;
          const wordFrame = frame - Math.round((word.startMs / 1000) * fps);
          const pop = spring({
            frame: wordFrame,
            fps,
            config: { damping: 11, stiffness: 260, mass: 0.6 },
          });
          const scale = isActive ? 1 + (activeScale - 1) * pop : 1;
          // Transforms don't affect layout, so reserve room for the pop or the
          // scaled word collides with its neighbours. ~0.7em per black-weight glyph.
          const estWidth = word.text.length * fontSize * 0.7;
          const reserve = ((scale - 1) * estWidth) / 2;
          return (
            <span
              key={`${word.startMs}-${i}`}
              style={{
                display: "inline-block",
                margin: `0 ${reserve}px`,
                transform: `scale(${scale})`,
                color: isActive ? activeColor : textColor,
                WebkitTextStroke: `${strokeWidth}px ${strokeColor}`,
                paintOrder: "stroke fill",
                textShadow: `0 ${fontSize * 0.06}px ${fontSize * 0.18}px rgba(0,0,0,0.45)`,
              }}
            >
              {word.text}
            </span>
          );
        })}
      </div>
    </AbsoluteFill>
  );
};

export default WordPopCaptions;

Remotion code you can paste anywhere, or install it: npx cutship add word-pop-captions

MIT License: use it in any video, personal or commercial. License

More from Cutship

Creator page →