2025-08-20 04:12:49 -06:00

161 lines
4.1 KiB
TypeScript

'use client';
import * as React from 'react';
import { motion, useInView, type UseInViewOptions } from 'motion/react';
import { cn } from '@workspace/ui/lib/utils';
function CursorBlinker({ className }: { className?: string }) {
return (
<motion.span
data-slot="cursor-blinker"
variants={{
blinking: {
opacity: [0, 0, 1, 1],
transition: {
duration: 1,
repeat: Infinity,
repeatDelay: 0,
ease: 'linear',
times: [0, 0.5, 0.5, 1],
},
},
}}
animate="blinking"
className={cn(
'inline-block h-5 w-[1px] translate-y-1 bg-black dark:bg-white',
className,
)}
/>
);
}
type TypingTextProps = Omit<React.ComponentProps<'span'>, 'children'> & {
duration?: number;
delay?: number;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
cursor?: boolean;
loop?: boolean;
holdDelay?: number;
text: string | string[];
cursorClassName?: string;
animateOnChange?: boolean;
};
function TypingText({
ref,
duration = 100,
delay = 0,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
cursor = false,
loop = false,
holdDelay = 1000,
text,
cursorClassName,
animateOnChange = true,
...props
}: TypingTextProps) {
const localRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
const [started, setStarted] = React.useState(false);
const [displayedText, setDisplayedText] = React.useState<string>('');
React.useEffect(() => {
// Reset animation when text changes (if animateOnChange is true)
if (animateOnChange) {
setStarted(false);
setDisplayedText('');
}
if (isInView) {
const timeoutId = setTimeout(() => {
setStarted(true);
}, delay);
return () => clearTimeout(timeoutId);
} else {
const timeoutId = setTimeout(() => {
setStarted(true);
}, delay);
return () => clearTimeout(timeoutId);
}
}, [isInView, delay, ...(animateOnChange ? [text] : [])]);
React.useEffect(() => {
if (!started) return;
const timeoutIds: Array<ReturnType<typeof setTimeout>> = [];
const texts: string[] = typeof text === 'string' ? [text] : text;
const typeText = (str: string, onComplete: () => void) => {
let currentIndex = 0;
const type = () => {
if (currentIndex <= str.length) {
setDisplayedText(str.substring(0, currentIndex));
currentIndex++;
const id = setTimeout(type, duration);
timeoutIds.push(id);
} else {
onComplete();
}
};
type();
};
const eraseText = (str: string, onComplete: () => void) => {
let currentIndex = str.length;
const erase = () => {
if (currentIndex >= 0) {
setDisplayedText(str.substring(0, currentIndex));
currentIndex--;
const id = setTimeout(erase, duration);
timeoutIds.push(id);
} else {
onComplete();
}
};
erase();
};
const animateTexts = (index: number) => {
typeText(texts[index] ?? '', () => {
const isLast = index === texts.length - 1;
if (isLast && !loop) {
return;
}
const id = setTimeout(() => {
eraseText(texts[index] ?? '', () => {
const nextIndex = isLast ? 0 : index + 1;
animateTexts(nextIndex);
});
}, holdDelay);
timeoutIds.push(id);
});
};
animateTexts(0);
return () => {
timeoutIds.forEach(clearTimeout);
};
}, [text, duration, started, loop, holdDelay]);
return (
<span ref={localRef} data-slot="typing-text" {...props}>
<motion.span>{displayedText}</motion.span>
{cursor && <CursorBlinker className={cursorClassName} />}
</span>
);
}
export { TypingText, type TypingTextProps };