A React typewriter text animation with reliable timing and cursor control. Customizable and accessible.
Built for the web,
import Typewriter from "@/components/typewriter";
function TypewriterDemo() {
return (
<p className="text-center text-xl font-semibold tracking-tight md:text-5xl font-serif">
Built for the web,
<Typewriter
text={["Efficient", "Interactive", "Customizable"]}
speedMs={45}
deleteSpeedMs={30}
waitTimeMs={1200}
initialDelayMs={200}
cursorClassName="ml-1 text-primary"
className="text-primary"
/>
</p>
);
}npm install motion clsx tailwind-mergebun add motion clsx tailwind-mergedeno add motion clsx tailwind-mergepnpm add motion clsx tailwind-mergeyarn add motion clsx tailwind-mergeCopy and paste the following code into your project.
"use client"
import { type ElementType, useEffect, useRef, useState, useCallback } from "react"
import { motion, useReducedMotion, type Variants } from "motion/react"
import { cn } from "@/lib/utils"
interface TypewriterProps {
/**
* Text or array of texts to type out
*/
text: string | string[]
/**
* HTML Tag to render the component as
* @default div
*/
as?: ElementType
/**
* Speed of typing in milliseconds
* @default 50
*/
speed?: number
/**
* Initial delay before typing starts
* @default 0
*/
initialDelay?: number
/**
* Time to wait between typing and deleting
* @default 2000
*/
waitTime?: number
/**
* Speed of deleting characters
* @default 40
*/
deleteSpeed?: number
/**
* Whether to loop through texts array
* @default true
*/
loop?: boolean
/**
* Optional class name for styling
*/
className?: string
/**
* Whether to show the cursor
* @default true
*/
showCursor?: boolean
/**
* Hide cursor while typing
* @default false
*/
hideCursorOnType?: boolean
/**
* Character or React node to use as cursor
* @default "|"
*/
cursorChar?: string | React.ReactNode
/**
* Animation variants for cursor
*/
cursorAnimationVariants?: {
initial: Variants["initial"]
animate: Variants["animate"]
}
/**
* Optional class name for cursor styling
*/
cursorClassName?: string
}
type Phase = "typing" | "waiting" | "deleting" | "paused" | "done"
const fadeMs = 350
const Typewriter = ({
text,
as: Tag = "div",
speed = 50,
initialDelay = 0,
waitTime = 2000,
deleteSpeed = 40,
loop = true,
className,
showCursor = true,
hideCursorOnType = false,
cursorChar = "|",
cursorClassName = "ml-1",
cursorAnimationVariants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: {
duration: 0.01,
repeat: Infinity,
repeatDelay: 0.4,
repeatType: "reverse",
},
},
},
...props
}: TypewriterProps & React.HTMLAttributes<HTMLElement>) => {
const [displayText, setDisplayText] = useState("")
const [fadeOpacity, setFadeOpacity] = useState(1)
const shouldReduceMotion = useReducedMotion() ?? false
// Use refs for internal state to avoid re-render cascades
const phaseRef = useRef<Phase>("paused")
const charIndexRef = useRef(0)
const textIndexRef = useRef(0)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const texts = Array.isArray(text) ? text : [text]
const textsRef = useRef(texts)
textsRef.current = texts
const clearTimeoutSafe = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
const tick = useCallback(() => {
const currentTexts = textsRef.current
const currentText = currentTexts[textIndexRef.current]
const phase = phaseRef.current
switch (phase) {
case "typing": {
if (charIndexRef.current < currentText.length) {
charIndexRef.current++
setDisplayText(currentText.slice(0, charIndexRef.current))
timeoutRef.current = setTimeout(tick, speed)
} else if (currentTexts.length > 1) {
// Finished typing, wait before deleting
phaseRef.current = "waiting"
timeoutRef.current = setTimeout(tick, waitTime)
} else {
phaseRef.current = "done"
}
break
}
case "waiting": {
// Start deleting
phaseRef.current = "deleting"
timeoutRef.current = setTimeout(tick, deleteSpeed)
break
}
case "deleting": {
if (charIndexRef.current > 0) {
charIndexRef.current--
setDisplayText(textsRef.current[textIndexRef.current].slice(0, charIndexRef.current))
timeoutRef.current = setTimeout(tick, deleteSpeed)
} else {
// Finished deleting, move to next text
const nextIndex = textIndexRef.current + 1
if (nextIndex >= currentTexts.length && !loop) {
phaseRef.current = "done"
return
}
textIndexRef.current = nextIndex % currentTexts.length
charIndexRef.current = 0
phaseRef.current = "paused"
// Apply waitTime before starting next text
timeoutRef.current = setTimeout(tick, waitTime)
}
break
}
case "paused": {
// Start typing next text
phaseRef.current = "typing"
timeoutRef.current = setTimeout(tick, speed)
break
}
case "done":
// Do nothing
break
default: {
const exhaustiveCheck: never = phase
return exhaustiveCheck
}
}
}, [speed, deleteSpeed, waitTime, loop])
useEffect(() => {
clearTimeoutSafe()
charIndexRef.current = 0
textIndexRef.current = 0
setFadeOpacity(1)
setDisplayText("")
if (shouldReduceMotion) {
const showCurrent = () => {
const currentTexts = textsRef.current
setDisplayText(currentTexts[textIndexRef.current])
setFadeOpacity(1)
phaseRef.current = "waiting"
if (currentTexts.length <= 1) {
phaseRef.current = "done"
return
}
timeoutRef.current = setTimeout(() => {
const nextIndex = textIndexRef.current + 1
if (nextIndex >= currentTexts.length && !loop) {
phaseRef.current = "done"
return
}
// Fade out, swap text, fade in
setFadeOpacity(0)
phaseRef.current = "paused"
timeoutRef.current = setTimeout(() => {
textIndexRef.current = nextIndex % currentTexts.length
showCurrent()
}, fadeMs)
}, waitTime)
}
phaseRef.current = "paused"
timeoutRef.current = setTimeout(showCurrent, initialDelay)
return () => {
clearTimeoutSafe()
}
}
phaseRef.current = "typing"
timeoutRef.current = setTimeout(tick, initialDelay)
return () => {
clearTimeoutSafe()
}
}, [tick, initialDelay, clearTimeoutSafe, shouldReduceMotion, waitTime, loop])
// Determine if currently typing or deleting for cursor visibility
const isAnimating =
!shouldReduceMotion &&
(phaseRef.current === "typing" || phaseRef.current === "deleting")
return (
<Tag className={cn("inline whitespace-pre-wrap tracking-tight", className)} {...props}>
{shouldReduceMotion ? (
<motion.span
animate={{ opacity: fadeOpacity }}
transition={{ duration: fadeMs / 1000, ease: "easeInOut" }}
>
{displayText}
</motion.span>
) : (
<span>{displayText}</span>
)}
{showCursor && (
<motion.span
variants={shouldReduceMotion ? undefined : cursorAnimationVariants}
className={cn(
cursorClassName,
hideCursorOnType && isAnimating ? "hidden" : ""
)}
initial={shouldReduceMotion ? false : "initial"}
animate={shouldReduceMotion ? { opacity: 1 } : "animate"}
>
{cursorChar}
</motion.span>
)}
</Tag>
)
}
export default Typewriterimport Typewriter from "@/components/typewriter";<Typewriter text={["Design systems", "Interactive UI", "Production-ready docs"]} />All timing props are in milliseconds.
<Typewriter
text={["Typing and deleting demo"]}
speedMs={60}
deleteSpeedMs={35}
waitTimeMs={1400}
initialDelayMs={250}
/><Typewriter
text={["Custom", "cursor"]}
cursorChar="●"
cursorClassName="ml-2 text-primary"
hideCursorOnType
/><Typewriter as="h2" text={["H2 first", "H2 second"]} /><Typewriter text={["One", "Shot"]} loop={false} />| Prop | Type | Default | Description |
|---|---|---|---|
text | string | string[] | — | Text content to animate. |
as | keyof HTMLElementTagNameMap | "span" | Root HTML tag. |
speedMs | number | 50 | Delay in ms for each typed character. |
initialDelayMs | number | 0 | Delay before the first typing cycle starts. |
waitTimeMs | number | 2000 | Delay between typing/deleting phases and before the next text. |
deleteSpeedMs | number | 40 | Delay in ms for each deleted character. |
loop | boolean | true | When false, stops after the last text is typed/deleted cycle. |
showCursor | boolean | true | Toggles cursor visibility. |
hideCursorOnType | boolean | false | Hides cursor while typing/deleting is active. |
cursorChar | ReactNode | `" | "` |
cursorAnimationVariants | Variants | blinking opacity variant | Custom motion animation variants for cursor. |
cursorClassName | string | — | Extra classes for cursor element. |
className | string | — | Extra classes for root element. |
The component also accepts all valid HTML attributes for the selected as element.