Text Type
Text animation that types out a text, one letter at a time.
Example
Show code
Installation
Copy and paste the following code into your project.
"use client";
import { cn } from "@/lib/utils";
import { type Variants, motion } from "motion/react";
import { useEffect, useState } from "react";
type TextTypeProps = {
text: string | string[];
speed?: number;
initialDelay?: number;
waitTime?: number;
deleteSpeed?: number;
loop?: boolean;
className?: string;
showCursor?: boolean;
hideCursorOnType?: boolean;
cursorChar?: string | React.ReactNode;
cursorAnimationVariants?: {
initial: Variants["initial"];
animate: Variants["animate"];
};
cursorClassName?: string;
};
export default function Typewriter({
text,
speed = 50,
initialDelay = 0,
waitTime = 2000,
deleteSpeed = 30,
loop = true,
className,
showCursor = true,
hideCursorOnType = false,
cursorChar = "|",
cursorClassName = "ml-0.5",
cursorAnimationVariants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: {
duration: 0.01,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 0.4,
repeatType: "reverse",
},
},
},
}: TextTypeProps) {
const [displayText, setDisplayText] = useState("");
const [currentIndex, setCurrentIndex] = useState(0);
const [isDeleting, setIsDeleting] = useState(false);
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const texts = Array.isArray(text) ? text : [text];
useEffect(() => {
let timeout: NodeJS.Timeout;
const currentText = texts[currentTextIndex];
const startTyping = () => {
if (isDeleting) {
if (displayText === "") {
setIsDeleting(false);
if (currentTextIndex === texts.length - 1 && !loop) {
return;
}
setCurrentTextIndex((prev) => (prev + 1) % texts.length);
setCurrentIndex(0);
timeout = setTimeout(() => {}, waitTime);
} else {
timeout = setTimeout(() => {
setDisplayText((prev) => prev.slice(0, -1));
}, deleteSpeed);
}
} else {
if (currentText && currentIndex < currentText.length) {
timeout = setTimeout(() => {
setDisplayText((prev) => prev + currentText[currentIndex]);
setCurrentIndex((prev) => prev + 1);
}, speed);
} else if (texts.length > 1) {
timeout = setTimeout(() => {
setIsDeleting(true);
}, waitTime);
}
}
};
// Apply initial delay only at the start
if (currentIndex === 0 && !isDeleting && displayText === "") {
timeout = setTimeout(startTyping, initialDelay);
} else {
startTyping();
}
return () => clearTimeout(timeout);
}, [
currentIndex,
displayText,
isDeleting,
speed,
deleteSpeed,
waitTime,
texts,
currentTextIndex,
loop,
initialDelay,
]);
return (
<div className={`inline whitespace-pre-wrap tracking-tight ${className}`}>
<span>{displayText}</span>
{showCursor && (
<motion.span
variants={cursorAnimationVariants}
className={cn(
cursorClassName,
hideCursorOnType &&
(currentIndex < (texts[currentTextIndex]?.length ?? 0) ||
isDeleting)
? "hidden"
: "",
)}
initial="initial"
animate="animate"
>
{cursorChar}
</motion.span>
)}
</div>
);
}
"use client";
import { cn } from "@/lib/utils";
import { type Variants, motion } from "motion/react";
import { useEffect, useState } from "react";
type TextTypeProps = {
text: string | string[];
speed?: number;
initialDelay?: number;
waitTime?: number;
deleteSpeed?: number;
loop?: boolean;
className?: string;
showCursor?: boolean;
hideCursorOnType?: boolean;
cursorChar?: string | React.ReactNode;
cursorAnimationVariants?: {
initial: Variants["initial"];
animate: Variants["animate"];
};
cursorClassName?: string;
};
export default function Typewriter({
text,
speed = 50,
initialDelay = 0,
waitTime = 2000,
deleteSpeed = 30,
loop = true,
className,
showCursor = true,
hideCursorOnType = false,
cursorChar = "|",
cursorClassName = "ml-0.5",
cursorAnimationVariants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: {
duration: 0.01,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 0.4,
repeatType: "reverse",
},
},
},
}: TextTypeProps) {
const [displayText, setDisplayText] = useState("");
const [currentIndex, setCurrentIndex] = useState(0);
const [isDeleting, setIsDeleting] = useState(false);
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const texts = Array.isArray(text) ? text : [text];
useEffect(() => {
let timeout: NodeJS.Timeout;
const currentText = texts[currentTextIndex];
const startTyping = () => {
if (isDeleting) {
if (displayText === "") {
setIsDeleting(false);
if (currentTextIndex === texts.length - 1 && !loop) {
return;
}
setCurrentTextIndex((prev) => (prev + 1) % texts.length);
setCurrentIndex(0);
timeout = setTimeout(() => {}, waitTime);
} else {
timeout = setTimeout(() => {
setDisplayText((prev) => prev.slice(0, -1));
}, deleteSpeed);
}
} else {
if (currentText && currentIndex < currentText.length) {
timeout = setTimeout(() => {
setDisplayText((prev) => prev + currentText[currentIndex]);
setCurrentIndex((prev) => prev + 1);
}, speed);
} else if (texts.length > 1) {
timeout = setTimeout(() => {
setIsDeleting(true);
}, waitTime);
}
}
};
// Apply initial delay only at the start
if (currentIndex === 0 && !isDeleting && displayText === "") {
timeout = setTimeout(startTyping, initialDelay);
} else {
startTyping();
}
return () => clearTimeout(timeout);
}, [
currentIndex,
displayText,
isDeleting,
speed,
deleteSpeed,
waitTime,
texts,
currentTextIndex,
loop,
initialDelay,
]);
return (
<div className={`inline whitespace-pre-wrap tracking-tight ${className}`}>
<span>{displayText}</span>
{showCursor && (
<motion.span
variants={cursorAnimationVariants}
className={cn(
cursorClassName,
hideCursorOnType &&
(currentIndex < (texts[currentTextIndex]?.length ?? 0) ||
isDeleting)
? "hidden"
: "",
)}
initial="initial"
animate="animate"
>
{cursorChar}
</motion.span>
)}
</div>
);
}
Props
Prop | Type | Default |
---|---|---|
text | string | string[] | - |
speed | number | 50 |
initialDelay | number | - |
waitTime | number | 2000 |
loop | boolean | true |
deleteSpeed | number | 30 |
className | string | undefined |
showCursor | boolean | true |
hideCursorOnType | boolean | false |
cursorChar | string | React.ReactNode | | |
cursorClassName | string | "ml-0.5" |
Edit on GitHub
Last updated on