Kinetic Headline
Headlines that morph font-weight and width per character on hover with a spring stagger
textheadlinevariable-fontmorphkinetic
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useEffect, useRef, useState } from "react";
interface KineticHeadlineProps {
text: string;
/** Resting weight (100..900). Default: 300 */
fromWeight?: number;
/** Active weight (100..900). Default: 800 */
toWeight?: number;
/** Per-char stagger in ms. Default: 40 */
stagger?: number;
/** Trigger mode. Default: 'hover' */
trigger?: "hover" | "loop";
/** Loop interval in ms when trigger='loop'. Default: 3000 */
loopInterval?: number;
className?: string;
}
export default function KineticHeadline({
text,
fromWeight = 300,
toWeight = 800,
stagger = 40,
trigger = "hover",
loopInterval = 3000,
className = "",
}: KineticHeadlineProps) {
const chars = Array.from(text);
const [weights, setWeights] = useState<number[]>(() =>
chars.map(() => fromWeight)
);
const [spacings, setSpacings] = useState<string[]>(() =>
chars.map(() => "-0.01em")
);
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearAll = () => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current = [];
};
const cascade = (targetWeight: number, targetSpacing: string) => {
clearAll();
chars.forEach((_, i) => {
const t = setTimeout(() => {
setWeights((prev) => {
const next = [...prev];
next[i] = targetWeight;
return next;
});
setSpacings((prev) => {
const next = [...prev];
next[i] = targetSpacing;
return next;
});
}, i * stagger);
timeoutsRef.current.push(t);
});
};
const cascadeUp = () => cascade(toWeight, "0.02em");
const cascadeDown = () => cascade(fromWeight, "-0.01em");
// Loop mode
useEffect(() => {
if (trigger !== "loop") return;
let cancelled = false;
const runWave = () => {
if (cancelled) return;
cascadeUp();
const downDelay = chars.length * stagger + 400;
const downTimer = setTimeout(() => {
if (!cancelled) cascadeDown();
}, downDelay);
timeoutsRef.current.push(downTimer);
};
runWave();
const interval = setInterval(runWave, loopInterval);
return () => {
cancelled = true;
clearAll();
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trigger, loopInterval, stagger, toWeight, fromWeight, text]);
// Cleanup on unmount
useEffect(() => () => clearAll(), []);
const handlers =
trigger === "hover"
? {
onPointerEnter: cascadeUp,
onPointerLeave: cascadeDown,
}
: {};
return (
<span
{...handlers}
className={`inline-block cursor-default select-none text-5xl font-semibold leading-tight tracking-tight md:text-6xl ${className}`}
style={{
fontFamily:
'"Inter Variable", "Inter", system-ui, sans-serif',
}}
aria-label={text}
>
{chars.map((char, i) => (
<span
key={i}
aria-hidden="true"
style={{
display: "inline-block",
fontVariationSettings: `"wght" ${weights[i]}`,
letterSpacing: spacings[i],
transition:
"font-variation-settings 400ms cubic-bezier(0.2, 0.9, 0.3, 1), letter-spacing 400ms cubic-bezier(0.2, 0.9, 0.3, 1)",
whiteSpace: char === " " ? "pre" : undefined,
}}
>
{char === " " ? "\u00a0" : char}
</span>
))}
</span>
);
}