"use client"; import { useEffect, useRef } from "react"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; type SplitTextProps = { text: string; className?: string; delay?: number; duration?: number; ease?: string; splitType?: "chars" | "words" | "lines" | "chars,words" | "words,lines" | "chars,words,lines"; from?: gsap.TweenVars; to?: gsap.TweenVars; threshold?: number; rootMargin?: string; textAlign?: "left" | "center" | "right"; tag?: "p" | "h1" | "h2" | "h3" | "span"; onLetterAnimationComplete?: () => void; showCallback?: boolean; }; export default function SplitText({ text = "", className = "", delay = 50, duration = 1.25, ease = "power3.out", splitType = "chars", from = { opacity: 0, y: 40 }, to = { opacity: 1, y: 0 }, threshold = 0.1, rootMargin = "-100px", textAlign = "center", tag = "p", onLetterAnimationComplete, showCallback = false, }: SplitTextProps) { const ref = useRef(null); const animationCompletedRef = useRef(false); const onCompleteRef = useRef(onLetterAnimationComplete); useEffect(() => { onCompleteRef.current = onLetterAnimationComplete; }, [onLetterAnimationComplete]); useEffect(() => { if (typeof window === "undefined" || !ref.current || !text) return; if (animationCompletedRef.current) return; gsap.registerPlugin(ScrollTrigger); const el = ref.current; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduceMotion) { animationCompletedRef.current = true; onCompleteRef.current?.(); return; } const startPct = (1 - threshold) * 100; const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin); const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0; const marginUnit = marginMatch ? marginMatch[2] || "px" : "px"; const sign = marginValue === 0 ? "" : marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`; const start = `top ${startPct}%${sign}`; // Select targets based on splitType let targets: Element[] = []; if (splitType.includes("chars")) { targets = Array.from(el.querySelectorAll(".split-char")); } else if (splitType.includes("words")) { targets = Array.from(el.querySelectorAll(".split-word")); } else { targets = [el]; } // Set initial state gsap.set(targets, { ...from }); // Animate using scrollTrigger const anim = gsap.to(targets, { ...to, duration, ease, stagger: delay / 1000, scrollTrigger: { trigger: el, start, once: true, fastScrollEnd: true, anticipatePin: 0.4, }, onComplete: () => { animationCompletedRef.current = true; onCompleteRef.current?.(); }, force3D: true, }); return () => { ScrollTrigger.getAll().forEach((trigger) => { if (trigger.trigger === el) trigger.kill(); }); anim.kill(); }; }, [ delay, duration, ease, from, rootMargin, showCallback, splitType, text, threshold, to, ]); const Tag = tag; const words = text.split(" "); return ( { ref.current = node; }} className={`split-parent ${className}`.trim()} style={{ textAlign, overflow: "hidden", display: "inline-block", whiteSpace: "normal", wordWrap: "break-word", willChange: "transform, opacity", }} > {splitType.includes("chars") ? ( words.map((word, wIdx) => ( {word.split("").map((char, cIdx) => ( {char} ))} {wIdx < words.length - 1 ? "\u00A0" : ""} )) ) : splitType.includes("words") ? ( words.map((word, wIdx) => ( {word} {wIdx < words.length - 1 ? "\u00A0" : ""} )) ) : ( text )} ); }