Animation
TextType
Typewriter that types, pauses and deletes a list of lines with a blinking cursor.
npx bezel-add add text-typepackages/ui/src/animation/TextType.tsx · 227 lines
"use client";
import {
useEffect,
useRef,
useState,
createElement,
useMemo,
useCallback,
type ElementType,
type ReactNode,
type HTMLAttributes,
} from "react";
import gsap from "gsap";
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}
export type TextTypeProps = {
text: string | string[];
as?: ElementType;
typingSpeed?: number;
initialDelay?: number;
pauseDuration?: number;
deletingSpeed?: number;
loop?: boolean;
className?: string;
showCursor?: boolean;
hideCursorWhileTyping?: boolean;
cursorCharacter?: ReactNode;
cursorClassName?: string;
cursorBlinkDuration?: number;
textColors?: string[];
variableSpeed?: { min: number; max: number };
onSentenceComplete?: (sentence: string, index: number) => void;
startOnVisible?: boolean;
reverseMode?: boolean;
} & Omit<HTMLAttributes<HTMLElement>, "children">;
/** Typewriter with GSAP-blinking cursor, optional delete/loop, and IntersectionObserver start. */
export function TextType({
text,
as: Component = "div",
typingSpeed = 50,
initialDelay = 0,
pauseDuration = 2000,
deletingSpeed = 30,
loop = true,
className = "",
showCursor = true,
hideCursorWhileTyping = false,
cursorCharacter = "|",
cursorClassName = "",
cursorBlinkDuration = 0.5,
textColors = [],
variableSpeed,
onSentenceComplete,
startOnVisible = false,
reverseMode = false,
...props
}: TextTypeProps) {
const [displayedText, setDisplayedText] = useState("");
const [currentCharIndex, setCurrentCharIndex] = useState(0);
const [isDeleting, setIsDeleting] = useState(false);
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const [isVisible, setIsVisible] = useState(!startOnVisible);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
const cursorRef = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLElement>(null);
const textArray = useMemo(() => (Array.isArray(text) ? text : [text]), [text]);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setPrefersReducedMotion(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const getRandomSpeed = useCallback(() => {
if (!variableSpeed) return typingSpeed;
const { min, max } = variableSpeed;
return Math.random() * (max - min) + min;
}, [variableSpeed, typingSpeed]);
const getCurrentTextColor = () => {
if (textColors.length === 0) return "inherit";
return textColors[currentTextIndex % textColors.length];
};
useEffect(() => {
if (!startOnVisible || !containerRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) setIsVisible(true);
});
},
{ threshold: 0.1 },
);
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [startOnVisible]);
useEffect(() => {
if (!showCursor || !cursorRef.current || prefersReducedMotion) return;
gsap.set(cursorRef.current, { opacity: 1 });
const tween = gsap.to(cursorRef.current, {
opacity: 0,
duration: cursorBlinkDuration,
repeat: -1,
yoyo: true,
ease: "power2.inOut",
});
return () => {
tween.kill();
};
}, [showCursor, cursorBlinkDuration, prefersReducedMotion]);
useEffect(() => {
if (prefersReducedMotion) {
setDisplayedText(textArray[0] ?? "");
return;
}
if (!isVisible) return;
let timeout: ReturnType<typeof setTimeout> | undefined;
const currentText = textArray[currentTextIndex];
const processedText = reverseMode ? currentText.split("").reverse().join("") : currentText;
const executeTypingAnimation = () => {
if (isDeleting) {
if (displayedText === "") {
setIsDeleting(false);
if (currentTextIndex === textArray.length - 1 && !loop) return;
onSentenceComplete?.(textArray[currentTextIndex], currentTextIndex);
setCurrentTextIndex((prev) => (prev + 1) % textArray.length);
setCurrentCharIndex(0);
timeout = setTimeout(() => {}, pauseDuration);
} else {
timeout = setTimeout(() => {
setDisplayedText((prev) => prev.slice(0, -1));
}, deletingSpeed);
}
} else if (currentCharIndex < processedText.length) {
timeout = setTimeout(
() => {
setDisplayedText((prev) => prev + processedText[currentCharIndex]);
setCurrentCharIndex((prev) => prev + 1);
},
variableSpeed ? getRandomSpeed() : typingSpeed,
);
} else if (textArray.length >= 1) {
if (!loop && currentTextIndex === textArray.length - 1) return;
timeout = setTimeout(() => setIsDeleting(true), pauseDuration);
}
};
if (currentCharIndex === 0 && !isDeleting && displayedText === "") {
timeout = setTimeout(executeTypingAnimation, initialDelay);
} else {
executeTypingAnimation();
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [
currentCharIndex,
displayedText,
isDeleting,
typingSpeed,
deletingSpeed,
pauseDuration,
textArray,
currentTextIndex,
loop,
initialDelay,
isVisible,
reverseMode,
variableSpeed,
onSentenceComplete,
getRandomSpeed,
prefersReducedMotion,
]);
const shouldHideCursor =
hideCursorWhileTyping &&
(currentCharIndex < textArray[currentTextIndex].length || isDeleting);
return (
<>
<style>{`
.hos-text-type { display: block; width: 100%; max-width: 36rem; }
.hos-text-type__content { text-wrap: pretty; }
.hos-text-type__cursor {
margin-left: 0.2rem;
display: inline-block;
opacity: 1;
color: hsl(38 45% 42%);
font-weight: 300;
}
.hos-text-type__cursor--hidden { display: none; }
`}</style>
{createElement(
Component,
{ ref: containerRef, className: cn("hos-text-type", className), ...props },
<span className="hos-text-type__content" style={{ color: getCurrentTextColor() || "inherit" }}>
{displayedText}
</span>,
showCursor && !prefersReducedMotion ? (
<span
ref={cursorRef}
className={cn(
"hos-text-type__cursor",
cursorClassName,
shouldHideCursor && "hos-text-type__cursor--hidden",
)}
>
{cursorCharacter}
</span>
) : null,
)}
</>
);
}