"use client"; import { useRef, useEffect, memo } from "react"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; import { cls } from "@/lib/utils"; gsap.registerPlugin(ScrollTrigger, SplitText); interface ScrollTriggerConfig { trigger: HTMLElement; start: string; end: string; markers: boolean; toggleActions: string; } type AnimationType = "entrance-slide" | "reveal-blur" | "background-highlight"; type VariantType = "trigger" | "words-trigger"; interface TextAnimationProps { type?: AnimationType; text: string; children?: React.ReactNode; className?: string; duration?: number; stagger?: number; start?: string; end?: string; variant?: VariantType; ariaLabel?: string; as?: "div" | "span" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"; gradientColors?: { from: string; to: string; }; } const ANIMATION_CONFIG = { trigger: { stagger: 0.0075, useDuration: false, duration: 0.6, }, "words-trigger": { stagger: 0.03, useDuration: false, duration: 0.6, }, } as const; const ANIMATION_TYPES = { "entrance-slide": { classPrefix: "slide", fromVars: { opacity: 0, yPercent: 50, }, toVars: { opacity: 1, yPercent: 0, ease: "power1", }, }, "reveal-blur": { classPrefix: "blur", fromVars: { autoAlpha: 0, filter: "blur(10px)", }, toVars: { autoAlpha: 1, filter: "blur(0px)", ease: "power1.inOut", }, }, "background-highlight": { classPrefix: "highlight", fromVars: { opacity: 0.25, }, toVars: { opacity: 1, ease: "power2.inOut", }, }, } as const; const TextAnimation = ({ type = "entrance-slide", text, children, className = "", duration = 1, stagger, start = "top 80%", end = "top 20%", variant = "trigger", ariaLabel, as = "div", gradientColors, }: TextAnimationProps) => { const textRef = useRef(null); const splitRef = useRef(null); const scrollTriggerRef = useRef(null); useEffect(() => { if (!textRef.current) return; const ctx = gsap.context(() => { const isWords = variant === "words-trigger"; const animConfig = ANIMATION_TYPES[type]; const classPrefix = animConfig.classPrefix; splitRef.current = new SplitText(textRef.current!, { type: isWords ? "lines,words" : "lines,words,chars", linesClass: `${classPrefix}-line`, wordsClass: `${classPrefix}-word`, charsClass: isWords ? undefined : `${classPrefix}-char`, }); const lines = splitRef.current.lines; if (type === "entrance-slide") { gsap.set(lines, { overflow: "hidden", display: as === "span" ? "inline" : undefined, }); } else if (as === "span") { gsap.set(lines, { display: "inline", }); } const words = splitRef.current.words; gsap.set(words, { display: "inline-block", whiteSpace: "nowrap", }); const animateTarget = isWords ? splitRef.current.words : splitRef.current.chars; const config = ANIMATION_CONFIG[variant]; const animationDuration = config.useDuration ? duration : config.duration!; const animationStagger = stagger ?? config.stagger; if (gradientColors) { animateTarget.forEach((element) => { gsap.set(element as HTMLElement, { backgroundImage: `linear-gradient(180deg, ${gradientColors.from} 0%, ${gradientColors.to} 100%)`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", display: "inline-block", }); }); } const scrollTriggerConfig: ScrollTriggerConfig = { trigger: textRef.current!, start: start, end: end, markers: false, toggleActions: "play none none none", }; if (type === "reveal-blur") { const tl = gsap.timeline({ scrollTrigger: scrollTriggerConfig, }); const parentElement = textRef.current; const splitInstance = splitRef.current; tl.fromTo( animateTarget, animConfig.fromVars, { ...animConfig.toVars, duration: animationDuration, stagger: animationStagger, force3D: true, onStart: function () { if (this._targets && this._targets.length > 0) { this._targets.forEach((target: HTMLElement) => { target.style.willChange = "filter, opacity, transform"; }); } }, onComplete: function () { if (this._targets && this._targets.length > 0) { this._targets.forEach((target: HTMLElement) => { target.style.willChange = "auto"; }); } if (parentElement && splitInstance) { gsap.set(parentElement, { opacity: 1, visibility: 'visible', filter: 'blur(0px)', transform: 'none' }); splitInstance.revert(); splitRef.current = null; } }, } ); scrollTriggerRef.current = tl.scrollTrigger as ScrollTrigger; } else { const parentElement = textRef.current; const splitInstance = splitRef.current; gsap.fromTo( animateTarget, animConfig.fromVars, { ...animConfig.toVars, duration: animationDuration, stagger: animationStagger, force3D: true, scrollTrigger: scrollTriggerConfig, onComplete: () => { if (parentElement && splitInstance) { gsap.set(parentElement, { opacity: 1, transform: 'none' }); splitInstance.revert(); splitRef.current = null; } }, } ); } }, textRef); const currentTextRef = textRef.current; return () => { if ( type === "reveal-blur" && scrollTriggerRef.current && scrollTriggerRef.current.trigger === currentTextRef ) { scrollTriggerRef.current.kill(); } ctx.revert(); if (splitRef.current) { splitRef.current.revert(); } }; }, [text, type, duration, stagger, start, end, variant, as, gradientColors]); const animConfig = ANIMATION_TYPES[type]; const Component = as; return ( {children || text?.replace(/-/g, '\u2011')} ); }; TextAnimation.displayName = "TextAnimation"; export default memo(TextAnimation);