import { AnimatePresence, MotionProps, motion } from 'motion/react' import { useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' 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 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 maxIterations = children.length const startTime = performance.now() let animationFrameId: number const animate = (currentTime: number) => { const elapsed = currentTime - startTime const progress = Math.min(elapsed / duration, 1) iterationCount.current = progress * maxIterations setDisplayText((currentText) => currentText.map((letter, index) => letter === ' ' ? letter : index <= iterationCount.current ? children[index] : characterSet[getRandomInt(characterSet.length)] ) ) if (progress < 1) { animationFrameId = requestAnimationFrame(animate) } else { setIsAnimating(false) } } animationFrameId = requestAnimationFrame(animate) return () => cancelAnimationFrame(animationFrameId) }, [children, duration, isAnimating, characterSet]) return ( {displayText.map((letter, index) => ( {letter.toUpperCase()} ))} ) }