All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m16s
180 lines
4.3 KiB
TypeScript
180 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { motion, type HTMLMotionProps } from "motion/react";
|
|
|
|
import { useIsInView, type UseIsInViewOptions } from "~/lib/use-is-in-view";
|
|
import { getStrictContext } from "~/lib/get-strict-context";
|
|
|
|
type TypingTextContextType = {
|
|
isTyping: boolean;
|
|
setIsTyping: (isTyping: boolean) => void;
|
|
};
|
|
|
|
const [TypingTextProvider, useTypingText] =
|
|
getStrictContext<TypingTextContextType>("TypingTextContext");
|
|
|
|
type TypingTextProps = React.ComponentProps<"span"> & {
|
|
duration?: number;
|
|
delay?: number;
|
|
loop?: boolean;
|
|
holdDelay?: number;
|
|
text: string | string[];
|
|
} & UseIsInViewOptions;
|
|
|
|
function TypingText({
|
|
ref,
|
|
children,
|
|
duration = 100,
|
|
delay = 0,
|
|
inView = false,
|
|
inViewMargin = "0px",
|
|
inViewOnce = true,
|
|
loop = false,
|
|
holdDelay = 1000,
|
|
text,
|
|
...props
|
|
}: TypingTextProps) {
|
|
const { ref: localRef, isInView } = useIsInView(
|
|
ref as React.Ref<HTMLElement>,
|
|
{
|
|
inView,
|
|
inViewOnce,
|
|
inViewMargin,
|
|
}
|
|
);
|
|
|
|
const [isTyping, setIsTyping] = React.useState(false);
|
|
const [started, setStarted] = React.useState(false);
|
|
const [displayedText, setDisplayedText] = React.useState<string>("");
|
|
|
|
React.useEffect(() => {
|
|
if (isInView) {
|
|
const timeoutId = setTimeout(() => {
|
|
setStarted(true);
|
|
}, delay);
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [isInView, delay]);
|
|
|
|
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) => {
|
|
setIsTyping(true);
|
|
let currentIndex = 0;
|
|
const type = () => {
|
|
if (currentIndex <= str.length) {
|
|
setDisplayedText(str.substring(0, currentIndex));
|
|
currentIndex++;
|
|
const id = setTimeout(type, duration);
|
|
timeoutIds.push(id);
|
|
} else {
|
|
setIsTyping(false);
|
|
onComplete();
|
|
}
|
|
};
|
|
type();
|
|
};
|
|
|
|
const eraseText = (str: string, onComplete: () => void) => {
|
|
setIsTyping(true);
|
|
let currentIndex = str.length;
|
|
const erase = () => {
|
|
if (currentIndex >= 0) {
|
|
setDisplayedText(str.substring(0, currentIndex));
|
|
currentIndex--;
|
|
const id = setTimeout(erase, duration);
|
|
timeoutIds.push(id);
|
|
} else {
|
|
setIsTyping(false);
|
|
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 (
|
|
<TypingTextProvider value={{ isTyping, setIsTyping }}>
|
|
<span ref={localRef} data-slot="typing-text" {...props}>
|
|
<motion.span>{displayedText}</motion.span>
|
|
{children}
|
|
</span>
|
|
</TypingTextProvider>
|
|
);
|
|
}
|
|
|
|
type TypingTextCursorProps = Omit<HTMLMotionProps<"span">, "children">;
|
|
|
|
function TypingTextCursor({
|
|
style,
|
|
variants,
|
|
...props
|
|
}: TypingTextCursorProps) {
|
|
const { isTyping } = useTypingText();
|
|
|
|
return (
|
|
<motion.span
|
|
data-slot="typing-text-cursor"
|
|
variants={{
|
|
blinking: {
|
|
opacity: [0, 0, 1, 1],
|
|
transition: {
|
|
duration: 1,
|
|
repeat: Infinity,
|
|
repeatDelay: 0,
|
|
ease: "linear",
|
|
times: [0, 0.5, 0.5, 1],
|
|
},
|
|
},
|
|
visible: {
|
|
opacity: 1,
|
|
},
|
|
...variants,
|
|
}}
|
|
animate={isTyping ? "visible" : "blinking"}
|
|
style={{
|
|
display: "inline-block",
|
|
height: "16px",
|
|
transform: "translateY(2px)",
|
|
width: "1px",
|
|
backgroundColor: "currentColor",
|
|
...style,
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export {
|
|
TypingText,
|
|
TypingTextCursor,
|
|
type TypingTextProps,
|
|
type TypingTextCursorProps,
|
|
};
|