"use client"; /** * KineticText — word/line masked reveal driven by ScrollTrigger. * * Splits the given text into words wrapped in overflow-hidden masks (manual * spans — no premium SplitText plugin) and slides each word up from below as it * enters the viewport, with a stagger. Renders the text as real, selectable DOM * so it stays accessible and SEO-safe; animation only transforms (perf-friendly). * * With reduced-motion, the text simply appears (no transform). */ import { createElement, useEffect, useRef, type ElementType } from "react"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger); type Props = { text: string; as?: ElementType; className?: string; /** Delay the whole reveal (s). */ delay?: number; /** Start animation on mount instead of on scroll (for the hero). */ immediate?: boolean; /** Mark a word index range as gradient-highlighted. */ highlight?: [number, number]; }; export default function KineticText({ text, as: Tag = "span", className = "", delay = 0, immediate = false, highlight, }: Props) { const ref = useRef(null); const words = text.split(" "); useEffect(() => { const el = ref.current; if (!el) return; const inner = gsap.utils.toArray(".ktext__in", el); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { gsap.set(inner, { yPercent: 0, opacity: 1 }); return; } const ctx = gsap.context(() => { gsap.set(inner, { yPercent: 118 }); const anim: gsap.TweenVars = { yPercent: 0, duration: 1.0, ease: "power4.out", stagger: 0.045, delay, }; if (!immediate) { anim.scrollTrigger = { trigger: el, start: "top 88%" }; } gsap.to(inner, anim); }, el); return () => ctx.revert(); }, [delay, immediate]); const children = words.map((w, i) => { const hot = highlight && i >= highlight[0] && i <= highlight[1] ? " ktext__word--grad" : ""; return ( {w} {i < words.length - 1 ? " " : ""} ); }); return createElement(Tag, { ref, className: `ktext ${className}` }, children); }