'use client'; import { cn } from '@workspace/ui/lib/utils'; import { AnimatePresence, motion, MotionProps } from 'motion/react'; import { useEffect, useRef, useState } from 'react'; type CharacterSet = string[] | readonly string[]; interface HyperTextProps extends MotionProps { /** The text content to be animated */ children: string; /** Optional className for styling */ className?: string; /** Duration of the animation in milliseconds */ duration?: number; /** Delay before animation starts in milliseconds */ delay?: number; /** Component to render as - defaults to div */ as?: React.ElementType; /** Whether to start animation when element comes into view */ startOnView?: boolean; /** Whether to trigger animation on hover */ animateOnHover?: boolean; /** Custom character set for scramble effect. Defaults to uppercase alphabet */ characterSet?: CharacterSet; } const DEFAULT_CHARACTER_SET = Object.freeze( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), ) as readonly string[]; const getRandomInt = (max: number): number => Math.floor(Math.random() * max); export default function HyperText({ children, className, duration = 800, delay = 0, as: Component = 'div', startOnView = false, animateOnHover = true, characterSet = DEFAULT_CHARACTER_SET, ...props }: HyperTextProps) { const MotionComponent = motion.create(Component, { forwardMotionProps: true, }); const [displayText, setDisplayText] = useState(() => children.split('')); const [isAnimating, setIsAnimating] = useState(false); const iterationCount = useRef(0); const elementRef = useRef(null); const handleAnimationTrigger = () => { if (animateOnHover && !isAnimating) { iterationCount.current = 0; setIsAnimating(true); } }; // Handle animation start based on view or delay useEffect(() => { if (!startOnView) { const startTimeout = setTimeout(() => { setIsAnimating(true); }, delay); return () => clearTimeout(startTimeout); } const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting) { setTimeout(() => { setIsAnimating(true); }, delay); observer.disconnect(); } }, { threshold: 0.1, rootMargin: '-30% 0px -30% 0px' }, ); if (elementRef.current) { observer.observe(elementRef.current); } return () => observer.disconnect(); }, [delay, startOnView]); // Handle scramble animation useEffect(() => { if (!isAnimating) return; const intervalDuration = duration / (children.length * 10); const maxIterations = children.length; const interval = setInterval(() => { if (iterationCount.current < maxIterations) { setDisplayText((currentText) => currentText.map((letter, index) => letter === ' ' ? letter : index <= iterationCount.current ? (children[index] ?? ' ') : (characterSet[getRandomInt(characterSet.length)] ?? ' '), ), ); iterationCount.current = iterationCount.current + 0.1; } else { setIsAnimating(false); clearInterval(interval); } }, intervalDuration); return () => clearInterval(interval); }, [children, duration, isAnimating, characterSet]); return ( {displayText.map((letter, index) => ( {letter.toUpperCase()} ))} ); }