A typewriter that types and erases one or several strings with natural timing and a blinking caret, starting on scroll if you like, with the whole text available to screen readers.
Build a landing page. dashboard. design system. mobile app
import { TypingText } from "@/components/ballmac/typing-text"
export default function TypingTextDemo() {
return (
<p className="max-w-lg text-center text-3xl font-semibold tracking-tight text-foreground">
Build a <TypingText text={["landing page", "dashboard", "design system", "mobile app"]} />
</p>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/typing-textInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presetsCopy the source into your project.
components/ballmac/typing-text.tsx// Ballmac UI: Typing Text. https://ui.ballmac.com/components/typing-text "use client" import * as React from "react" import { motion, useInView } from "motion/react" import { cn } from "@/lib/utils" import { useReducedMotionSafe } from "@/lib/ballmac/motion" type TypingTextProps = Omit<React.ComponentProps<"span">, "children"> & { /** One string to type, or several to type and erase in turn. */ text: string | string[] /** Milliseconds per typed character. */ typingSpeed?: number /** Milliseconds per erased character. */ deleteSpeed?: number /** Milliseconds to hold a finished string before erasing it. */ pause?: number /** Start again after the last string. A single string only loops when this is set. */ loop?: boolean /** Show a blinking caret. */ cursor?: boolean /** Wait for the text to scroll into view before typing. */ startOnView?: boolean /** Called after the final string is fully typed (when not looping). */ onComplete?: () => void } function TypingText({ text, typingSpeed = 55, deleteSpeed = 28, pause = 1600, loop, cursor = true, startOnView = false, onComplete, className, ...props }: TypingTextProps) { const list = React.useMemo(() => (Array.isArray(text) ? text : [text]), [text]) const looping = loop ?? list.length > 1 const reduce = useReducedMotionSafe() const ref = React.useRef<HTMLSpanElement>(null) const seen = useInView(ref, { once: true }) const active = !startOnView || seen const [shown, setShown] = React.useState("") const [done, setDone] = React.useState(false) const completeRef = React.useRef(onComplete) React.useEffect(() => { completeRef.current = onComplete }) React.useEffect(() => { if (!active || reduce) return let cancelled = false let timer: ReturnType<typeof setTimeout> let item = 0 let length = 0 let erasing = false setDone(false) const tick = () => { if (cancelled) return const current = Array.from(list[item] ?? "") if (!erasing) { length++ setShown(current.slice(0, length).join("")) if (length >= current.length) { const last = item === list.length - 1 if (last && !looping) { setDone(true) completeRef.current?.() return } erasing = true timer = setTimeout(tick, pause) return } timer = setTimeout(tick, typingSpeed * (0.7 + ((length * 7) % 5) / 8)) } else { length-- setShown(current.slice(0, Math.max(length, 0)).join("")) if (length <= 0) { erasing = false item = (item + 1) % list.length timer = setTimeout(tick, typingSpeed * 4) return } timer = setTimeout(tick, deleteSpeed) } } timer = setTimeout(tick, typingSpeed * 3) return () => { cancelled = true clearTimeout(timer) } }, [active, reduce, list, looping, typingSpeed, deleteSpeed, pause]) const visible = reduce ? (list[looping ? 0 : list.length - 1] ?? "") : shown const blinking = !reduce && !done return ( <span ref={ref} data-slot="typing-text" className={cn("inline", className)} {...props}> <span className="sr-only">{list.join(". ")}</span> <span aria-hidden="true"> {visible} {cursor && !reduce && ( <motion.span className="ms-px inline-block h-[1.05em] w-[0.1em] translate-y-[0.14em] bg-current" animate={blinking ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }} transition={blinking ? { duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1] } : { duration: 0 }} /> )} </span> </span> ) } export { TypingText, type TypingTextProps }Update the import paths to match your project setup.
Usage
import { TypingText } from "@/components/ballmac/typing-text"The full example is in the Code tab above.
Examples
Type once with a caret
$ pnpm dlx shadcn@latest add
@ballmac/button @ballmac/card @ballmac/dialogimport { TypingText } from "@/components/ballmac/typing-text"
export default function TypingTextOnce() {
return (
<div className="w-full max-w-md rounded-xl border bg-card p-4 font-mono text-sm shadow-xs">
<p className="text-muted-foreground">$ pnpm dlx shadcn@latest add</p>
<TypingText className="text-foreground" text="@ballmac/button @ballmac/card @ballmac/dialog" typingSpeed={40} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
text*One string to type, or several to type and erase in turn. | string | string[] | — |
typingSpeedMilliseconds per typed character. | number | 55 |
deleteSpeedMilliseconds per erased character. | number | 28 |
pauseMilliseconds to hold a finished string before erasing it. | number | 1600 |
loopStart again after the last string. A single string only loops when this is set. | boolean | — |
cursorShow a blinking caret. | boolean | true |
startOnViewWait for the text to scroll into view before typing. | boolean | false |
onCompleteCalled after the final string is fully typed (when not looping). | () => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | All strings are available as hidden text; the typing itself is aria-hidden |
| Reduced motion | The first string (or the last, when not looping) is shown whole with no caret blink |
Use with AI
<TypingText text={['one','two']} typingSpeed deleteSpeed pause loop cursor startOnView onComplete />. A single string types once unless loop is set. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Typing Text (@ballmac/typing-text) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Hero lines that cycle through use cases
- Terminal-style intros
Not for
- Streaming AI output (streaming-text)
- Long copy
Registry JSON: https://ui.ballmac.com/r/typing-text.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Word Rotate
Cycles one word of a sentence with a vertical slide and blur while its width springs to the next word, so the rest of the line reflows smoothly. Screen readers hear one stable sentence.
Terminal
A terminal window with command, output, success, error and comment lines, optional per-command copy buttons and a sequenced typing animation that respects reduced motion.
Text Animate
Reveals text by character, word or line with eight entrances (fade, blur, slide, scale, rotate), staggered and triggered on scroll, with the full text kept for screen readers.
Gradient Text
Text filled with a gradient of theme colors that slowly drifts, or a shiny variant that sweeps a glint of light across it on a pause. Static under reduced motion; plain text in forced colors.