Cutship
Sign inGet started
← All edits
cutship.ai

Component · Captions · 9:16

Marker Highlight Captions

by CutshipVerified

Clean word-by-word captions where a hand-drawn highlighter stroke sweeps behind each word as it's spoken — readable over any talking head.

New

Included with Pro

Sign up free to Make this

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

components/remotion/marker-highlight-captions/component.tsx

import React, { useEffect, useMemo, useState } from "react";
import {
  AbsoluteFill,
  Easing,
  continueRender,
  delayRender,
  interpolate,
  random,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { loadFont } from "@remotion/google-fonts/Inter";
import { getAvailableFonts } from "@remotion/google-fonts";
import type { CaptionWord, MarkerHighlightCaptionsProps } from "./schema";

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

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 = ["800", "700", "900", "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;
};

const sweepEase = Easing.bezier(0.55, 0, 0.25, 1);

export const MarkerHighlightCaptions: React.FC<MarkerHighlightCaptionsProps> = ({
  words,
  wordsPerPage,
  pauseBreakMs,
  fontSize,
  textColor,
  markerColor: baseMarker,
  markerTextColor,
  upcomingOpacity,
  sweepMs,
  uppercase,
  positionY: basePositionY,
  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 markerColor = brandKit?.colors[0] ?? baseMarker;
  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 pageIndex = pages.findIndex((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;
  });
  const page = pages[pageIndex];
  if (!page) return null;

  const msToFrame = (ms: number) => (ms / 1000) * fps;
  const pageFrame = frame - msToFrame(page.startMs);
  const pageIn = interpolate(pageFrame, [0, 6], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
  const sweepFrames = msToFrame(sweepMs);

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

  return (
    <AbsoluteFill style={{ pointerEvents: "none" }}>
      <div
        style={{
          position: "absolute",
          left: sidePadding,
          right: sidePadding * 1.6,
          top: height * positionY,
          transform: `translateY(-50%) translateY(${(1 - pageIn) * 14}px)`,
          opacity: pageIn,
          filter: pageIn < 1 ? `blur(${(1 - pageIn) * 6}px)` : undefined,
          display: "flex",
          flexWrap: "wrap",
          justifyContent: "center",
          alignItems: "center",
          columnGap: fontSize * 0.3,
          rowGap: fontSize * 0.16,
          fontFamily,
          fontWeight: 800,
          fontSize,
          lineHeight: 1.12,
          letterSpacing: "-0.025em",
          textAlign: "center",
          textTransform: uppercase ? "uppercase" : "none",
        }}
      >
        {page.words.map((word, i) => {
          const startF = msToFrame(word.startMs);
          const next = page.words[i + 1];
          // The ink holds until the next word starts, then slides off to the right
          // so the highlight reads as one marker travelling along the line.
          const releaseF = next ? msToFrame(next.startMs) : msToFrame(word.endMs + 300);
          const draw = interpolate(frame, [startF, startF + sweepFrames], [0, 1], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
            easing: sweepEase,
          });
          const release = interpolate(frame, [releaseF, releaseF + sweepFrames * 0.7], [0, 1], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
            easing: sweepEase,
          });
          const inked = draw > 0 && release < 1;
          const spoken = frame >= startF;
          const seed = `${word.startMs}-${word.text}`;
          const tilt = (random(`tilt-${seed}`) - 0.5) * 2.4;
          const skew = -7 - random(`skew-${seed}`) * 4;
          const lift = -fontSize * 0.025 * draw * (1 - release);

          // Ink spans [release, draw] of the overlay box, which is the word plus PAD
          // each side. The white copy shows everywhere else.
          const PAD = 0.3;
          const edge = (p: number) => `calc(${p} * (100% + ${PAD * 2}em) - ${PAD}em)`;
          const whiteClip = !inked
            ? "none"
            : release > 0
              ? `inset(-50% calc(100% - ${edge(release)}) -50% 0)`
              : `inset(-50% 0 -50% ${edge(draw)})`;
          const inkClip = `inset(-50% ${(1 - draw) * 100}% -50% ${release * 100}%)`;
          const shadow = `0 ${fontSize * 0.02}px ${fontSize * 0.05}px rgba(0,0,0,0.5), 0 ${fontSize * 0.04}px ${fontSize * 0.32}px rgba(0,0,0,0.5)`;

          return (
            <span
              key={`${word.startMs}-${i}`}
              style={{
                position: "relative",
                display: "inline-block",
                transform: `translateY(${lift}px)`,
                opacity: spoken ? 1 : upcomingOpacity,
              }}
            >
              <span style={{ color: textColor, textShadow: shadow, clipPath: whiteClip, display: "inline-block" }}>
                {word.text}
              </span>
              {inked ? (
                <span
                  style={{
                    position: "absolute",
                    top: 0,
                    bottom: 0,
                    left: `-${PAD}em`,
                    right: `-${PAD}em`,
                    clipPath: inkClip,
                  }}
                >
                  {/* Ink body: chisel-tip highlighter with uneven rounded ends. */}
                  <span
                    style={{
                      position: "absolute",
                      left: `${PAD - 0.15}em`,
                      right: `${PAD - 0.15}em`,
                      top: "0.12em",
                      bottom: "0.03em",
                      transform: `rotate(${tilt}deg) skewX(${skew}deg)`,
                      borderRadius: "0.34em 0.2em 0.3em 0.24em / 0.42em 0.3em 0.46em 0.34em",
                      background: `linear-gradient(90deg, ${markerColor}E0 0%, ${markerColor} 10%, ${markerColor} 86%, ${markerColor}D0 100%)`,
                      boxShadow: `0 ${fontSize * 0.05}px ${fontSize * 0.24}px rgba(0,0,0,0.3)`,
                      overflow: "hidden",
                    }}
                  >
                    {/* Slightly denser overlap streak along the lower edge. */}
                    <span
                      style={{
                        position: "absolute",
                        left: "3%",
                        right: "5%",
                        bottom: "10%",
                        height: "16%",
                        borderRadius: "0.2em",
                        background: "rgba(0,0,0,0.06)",
                      }}
                    />
                  </span>
                  <span style={{ position: "absolute", left: `${PAD}em`, top: 0, color: markerTextColor, whiteSpace: "nowrap" }}>
                    {word.text}
                  </span>
                </span>
              ) : null}
            </span>
          );
        })}
      </div>
    </AbsoluteFill>
  );
};

export default MarkerHighlightCaptions;

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

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

More from Cutship

Creator page →