Typewriter
Types, holds and deletes through a list of words.
1.0 KB gzippedDeps: motion/reactReduced-motion safeBase UI & Radix compatible
Build it faster.
Installation
npx shadcn@latest add https://velora.colorlib.com/r/typewriter.jsonInstalls with: motion
Source
"use client";
import { useEffect, useState } from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
interface TypewriterProps extends React.HTMLAttributes<HTMLSpanElement> {
words: string[];
/** ms per typed character */
typeSpeed?: number;
/** ms per deleted character */
deleteSpeed?: number;
/** ms to hold a completed word */
holdTime?: number;
loop?: boolean;
cursor?: boolean;
}
export function Typewriter({
words,
typeSpeed = 70,
deleteSpeed = 40,
holdTime = 1800,
loop = true,
cursor = true,
className,
...props
}: TypewriterProps) {
const reducedMotion = useReducedMotion();
const [wordIndex, setWordIndex] = useState(0);
const [text, setText] = useState("");
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (reducedMotion || words.length === 0) return;
const word = words[wordIndex % words.length];
let timeout: ReturnType<typeof setTimeout>;
if (!deleting && text.length < word.length) {
timeout = setTimeout(() => setText(word.slice(0, text.length + 1)), typeSpeed);
} else if (!deleting && text.length === word.length) {
const isLast = wordIndex === words.length - 1;
if (loop || !isLast) {
timeout = setTimeout(() => setDeleting(true), holdTime);
}
} else if (deleting && text.length > 0) {
timeout = setTimeout(() => setText(word.slice(0, text.length - 1)), deleteSpeed);
} else if (deleting) {
timeout = setTimeout(() => {
setDeleting(false);
setWordIndex((i) => (i + 1) % words.length);
}, deleteSpeed);
}
return () => clearTimeout(timeout);
}, [text, deleting, wordIndex, words, typeSpeed, deleteSpeed, holdTime, loop, reducedMotion]);
if (reducedMotion) {
return (
<span className={cn(className)} {...props}>
{words[0]}
</span>
);
}
const longest = words.reduce((a, b) => (b.length > a.length ? b : a), "");
// Both spans share one grid cell: the invisible longest word reserves
// the final size, so typing never shifts surrounding layout.
return (
<span
data-slot="typewriter"
className={cn("inline-grid text-left", className)}
{...props}
>
<span className="sr-only">{words[0]}</span>
<span aria-hidden className="invisible col-start-1 row-start-1">
{longest}
{cursor && <span className="font-light">|</span>}
</span>
<span aria-hidden className="col-start-1 row-start-1">
{text}
{cursor && (
<span className="animate-pulse font-light">|</span>
)}
</span>
</span>
);
}