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 →