mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-02-06 11:40:28 -05:00
134 lines
3.8 KiB
TypeScript
134 lines
3.8 KiB
TypeScript
'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<string[]>(() => children.split(''));
|
|
const [isAnimating, setIsAnimating] = useState(false);
|
|
const iterationCount = useRef(0);
|
|
const elementRef = useRef<HTMLElement>(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 (
|
|
<MotionComponent
|
|
ref={elementRef}
|
|
className={cn('overflow-hidden py-2 text-4xl font-bold', className)}
|
|
onMouseEnter={handleAnimationTrigger}
|
|
{...props}
|
|
>
|
|
<AnimatePresence>
|
|
{displayText.map((letter, index) => (
|
|
<motion.span key={index} className={cn('font-mono', letter === ' ' ? 'w-3' : '')}>
|
|
{letter.toUpperCase()}
|
|
</motion.span>
|
|
))}
|
|
</AnimatePresence>
|
|
</MotionComponent>
|
|
);
|
|
}
|