Prism
← All primitives

KineticHeadline

Motion

Splits a headline into per-character spans and animates them in with a 3D stagger (rise + rotateX + blur). No paid plugins.

$npx shadcn@latest add https://prism.icglabs.co/r/kinetic-headline.json
Dependencies:gsap
View raw manifest →

components/prism/KineticHeadline.tsx
"use client";

import { createElement, useEffect, useRef, type ElementType } from "react";
import gsap from "gsap";

/**
 * KineticHeadline — splits a headline into per-character spans and animates them
 * in with a 3D stagger (rise + rotateX + blur). No paid plugins: the split is
 * done in React, GSAP just choreographs. Honors prefers-reduced-motion.
 */
export function KineticHeadline({
  text,
  className = "",
  as = "h1",
  delay = 0.1,
}: {
  text: string;
  className?: string;
  as?: "h1" | "h2" | "h3" | "p";
  delay?: number;
}) {
  const ref = useRef<HTMLElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const chars = el.querySelectorAll<HTMLElement>("[data-char]");
    const ctx = gsap.context(() => {
      gsap.from(chars, {
        yPercent: 120,
        opacity: 0,
        rotateX: -80,
        filter: "blur(8px)",
        stagger: 0.022,
        duration: 0.9,
        ease: "expo.out",
        delay,
      });
    }, el);
    return () => ctx.revert();
  }, [text, delay]);

  const words = text.split(" ");
  const Tag = as as ElementType;

  return createElement(
    Tag,
    { ref, className, style: { perspective: 600 }, "aria-label": text },
    words.map((word, wi) => (
      <span key={wi} aria-hidden style={{ display: "inline-block", whiteSpace: "nowrap" }}>
        {Array.from(word).map((ch, ci) => (
          <span
            key={ci}
            data-char
            style={{ display: "inline-block", transformStyle: "preserve-3d", willChange: "transform" }}
          >
            {ch}
          </span>
        ))}
        {wi < words.length - 1 && (
          <span style={{ display: "inline-block", width: "0.28em" }}> </span>
        )}
      </span>
    ))
  );
}
Live demo — read-only. Every section is a real, copyable primitive.