← All primitives
KineticHeadline
MotionSplits 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.jsonDependencies: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>
))
);
}