Cutship
Sign inGet started
← All edits
cutship.ai

Component · Text/Hooks · 9:16

Kinetic Headline

by CutshipVerified

A 2–5 word hook that slams in word by word out of a motion blur, then a highlight bar sweeps behind the accent word and the whole line settles with a slow drift.

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/kinetic-headline/component.tsx

import React, { useEffect, useState } from "react";
import {
  AbsoluteFill,
  Easing,
  continueRender,
  delayRender,
  interpolate,
  interpolateColors,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { loadFont } from "@remotion/google-fonts/InterTight";
import { getAvailableFonts } from "@remotion/google-fonts";
import type { KineticHeadlineProps } from "./schema";

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

/** 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", "900", "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;
};

/** "a *b* c" -> [{text:"a"}, {text:"b", accent}, {text:"c"}] */
const parse = (text: string) => {
  const words: { text: string; accent: boolean }[] = [];
  text.split(/(\*[^*]+\*)/g).forEach((chunk) => {
    const accent = chunk.startsWith("*") && chunk.endsWith("*") && chunk.length > 2;
    const clean = accent ? chunk.slice(1, -1) : chunk;
    clean
      .split(/\s+/)
      .filter(Boolean)
      .forEach((w) => words.push({ text: w, accent }));
  });
  return words;
};

const ease = Easing.bezier(0.65, 0, 0.35, 1);

export const KineticHeadline: React.FC<KineticHeadlineProps> = ({
  text,
  fontSize: baseFontSize,
  textColor,
  accentColor: baseAccent,
  accentTextColor,
  uppercase,
  staggerFrames,
  motionBlur,
  holdSeconds,
  positionY: basePositionY,
  backdrop,
  brandKit,
}) => {
  const frame = useCurrentFrame();
  const { fps, width, height } = useVideoConfig();
  const brandFont = useBrandFont(brandKit?.fonts[0]);
  const fontFamily = brandFont ?? defaultFont;
  const accentColor = brandKit?.colors[0] ?? baseAccent;
  const positionY =
    brandKit?.captionPosition === "top" ? 0.3 : brandKit?.captionPosition === "bottom" ? 0.6 : basePositionY;

  const words = parse(text);
  const holdFrames = Math.round(holdSeconds * fps);
  if (frame >= holdFrames || words.length === 0) return null;

  // TikTok/Reels: the action rail sits on the right from ~35% to 80% height.
  const left = width * 0.075;
  const right = width * 0.14;
  const available = width - left - right;
  // Shrink to fit the longest word on one line (~0.52em per glyph at 800 weight).
  const longest = Math.max(...words.map((w) => w.text.length + (w.accent ? 0.5 : 0)));
  const fontSize = Math.min(baseFontSize, available / (longest * 0.52));

  const landFrame = (i: number) => i * staggerFrames;
  const lastLand = landFrame(words.length - 1) + 9;
  const sweepStart = lastLand + 2;
  const sweep = interpolate(frame, [sweepStart, sweepStart + 13], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: ease,
  });

  // Slow, continuous settle after the slam: a hair of scale and lift.
  const drift = interpolate(frame, [lastLand, holdFrames], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const exit = interpolate(frame, [holdFrames - 9, holdFrames], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.in(Easing.cubic),
  });
  const backdropIn = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: "clamp" }) * (1 - exit);

  return (
    <AbsoluteFill style={{ pointerEvents: "none" }}>
      {backdrop ? (
        <AbsoluteFill
          style={{
            opacity: backdropIn,
            background: `radial-gradient(ellipse 85% 22% at 50% ${positionY * 100}%, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.22) 55%, rgba(0,0,0,0) 100%)`,
          }}
        />
      ) : null}
      <div
        style={{
          position: "absolute",
          left,
          right,
          top: height * positionY,
          display: "flex",
          flexWrap: "wrap",
          justifyContent: "center",
          alignItems: "baseline",
          columnGap: fontSize * 0.24,
          rowGap: fontSize * 0.14,
          transform: `translateY(-50%) translateY(${-drift * 14 - exit * 30}px) scale(${1 + drift * 0.035 - exit * 0.05})`,
          filter: exit > 0 ? `blur(${exit * 18}px)` : undefined,
          opacity: 1 - exit,
          fontFamily,
          fontWeight: 800,
          fontSize,
          lineHeight: 1.0,
          letterSpacing: "-0.045em",
          textAlign: "center",
          textTransform: uppercase ? "uppercase" : "none",
        }}
      >
        {words.map((w, i) => {
          const local = frame - landFrame(i);
          const s = spring({ frame: local, fps, config: { damping: 15, stiffness: 210, mass: 0.75 } });
          const visible = local >= 0;
          const scale = interpolate(s, [0, 1], [2.1, 1]);
          const blur = Math.max(0, motionBlur * (1 - Math.min(1, s * 1.15)));
          const opacity = interpolate(local, [0, 3], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
          const lift = (1 - s) * -fontSize * 0.25;
          // Zoom trail: a ghost copy left behind at a larger scale while the word lands.
          const trail = visible ? Math.max(0, 1 - s * 1.3) : 0;
          const color = w.accent ? interpolateColors(sweep, [0.35, 0.7], [textColor, accentTextColor]) : textColor;

          return (
            <span
              key={i}
              style={{
                position: "relative",
                display: "inline-block",
                margin: w.accent ? "0 0.1em" : 0,
                opacity: visible ? opacity : 0,
                transform: `translateY(${lift}px) scale(${scale})`,
                filter: blur > 0.3 ? `blur(${blur}px)` : undefined,
                isolation: "isolate",
              }}
            >
              {w.accent ? (
                <span
                  style={{
                    position: "absolute",
                    left: "-0.13em",
                    right: "-0.13em",
                    top: "0.06em",
                    bottom: "-0.1em",
                    background: accentColor,
                    borderRadius: "0.12em",
                    transform: "rotate(-1.6deg)",
                    clipPath: `inset(0 ${(1 - sweep) * 100}% 0 0)`,
                    boxShadow: `0 ${fontSize * 0.08}px ${fontSize * 0.35}px ${accentColor}40`,
                    zIndex: -1,
                  }}
                />
              ) : null}
              {trail > 0.02 ? (
                <span
                  aria-hidden
                  style={{
                    position: "absolute",
                    inset: 0,
                    color: textColor,
                    opacity: trail * 0.35,
                    transform: `scale(${1 + trail * 0.5})`,
                    filter: `blur(${motionBlur * 0.8}px)`,
                  }}
                >
                  {w.text}
                </span>
              ) : null}
              <span
                style={{
                  position: "relative",
                  color,
                  textShadow: sweep > 0.5 && w.accent ? "none" : `0 ${fontSize * 0.04}px ${fontSize * 0.3}px rgba(0,0,0,0.35)`,
                }}
              >
                {w.text}
              </span>
            </span>
          );
        })}
      </div>
    </AbsoluteFill>
  );
};

export default KineticHeadline;

Remotion code you can paste anywhere, or install it: npx cutship add kinetic-headline

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

More from Cutship

Creator page →