Kinetic Headline

Headlines that morph font-weight and width per character on hover with a spring stagger

textheadlinevariable-fontmorphkinetic
Install dependencies
$npm install framer-motion
Preview

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>
  );
}