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 →