"use client"; import { useRef, useEffect } from "react"; interface CinematicTextProps { text: string; className?: string; tag?: "h1" | "h2" | "h3" | "p" | "span"; delay?: number; splitBy?: "chars" | "words" | "lines"; } export default function CinematicText({ text, className = "", tag: Tag = "h2", delay = 0, splitBy = "chars", }: CinematicTextProps) { const containerRef = useRef(null); useEffect(() => { const el = containerRef.current; if (!el) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; let cleanup: (() => void) | undefined; const setup = async () => { const [{ gsap }, { ScrollTrigger }] = await Promise.all([ import("gsap"), import("gsap/ScrollTrigger"), ]); gsap.registerPlugin(ScrollTrigger); const spans = el.querySelectorAll("[data-char]"); if (spans.length === 0) return; gsap.set(spans, { opacity: 0, y: 60, rotateX: -90, transformOrigin: "center bottom", transformPerspective: 600, }); const tl = gsap.timeline({ scrollTrigger: { trigger: el, start: "top 80%", toggleActions: "play none none none", }, }); tl.to(spans, { opacity: 1, y: 0, rotateX: 0, duration: 0.8, ease: "back.out(1.2)", stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12, delay, }); cleanup = () => { tl.scrollTrigger?.kill(); tl.kill(); }; }; void setup(); return () => { cleanup?.(); }; }, [text, delay, splitBy]); const renderContent = () => { if (splitBy === "chars") { return text.split("").map((char, i) => ( {char === " " ? "\u00A0" : char} )); } if (splitBy === "words") { return text.split(" ").map((word, i) => ( {word} {i < text.split(" ").length - 1 && "\u00A0"} )); } // lines return text.split("\n").map((line, i) => ( {line} )); }; return (
{renderContent()}
); }