Types text character by character with a blinking cursor, showing the full string immediately under reduced motion.
npx shadcn@latest add @paragon/typewriter"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type Phase = "idle" | "typing" | "holding" | "deleting" | "done";
/**
* Deterministic per-character pacing — a short rest after punctuation reads
* like a human breath. Pure function of the character, no randomness.
*/
const PAUSE_AFTER: Record<string, number> = {
",": 3,
";": 3,
":": 3,
".": 8,
"!": 8,
"?": 8,
"…": 8,
"\n": 8,
};
function delayAfter(prev: string | undefined, base: number): number {
return base * ((prev !== undefined && PAUSE_AFTER[prev]) || 1);
}
function usePrefersReducedMotion(): boolean {
const subscribe = React.useCallback((onChange: () => void) => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return React.useSyncExternalStore(
subscribe,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface TypewriterProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The string to type — or several, cycled through in order. */
text: string | string[];
/** Milliseconds per character while typing. */
speed?: number;
/** Milliseconds per character while deleting. */
deleteSpeed?: number;
/** Milliseconds the finished string holds before deleting (when cycling). */
holdDelay?: number;
/** Milliseconds before the first character appears. */
startDelay?: number;
/** Cycle forever. Defaults to true when `text` is an array of 2+ strings. */
loop?: boolean;
/** Show the blinking caret. */
cursor?: boolean;
/** Renders the full string with no typing and no blink. */
static?: boolean;
}
/**
* Types text character by character with a blinking caret. The caret stays
* solid while typing and blinks only at rest, like a real editor. Reserves
* the footprint of the longest string, so surrounding layout never shifts.
* Under `prefers-reduced-motion` (or `static`) the full string renders
* immediately; typing pauses while the element is offscreen.
*/
export function Typewriter({
text,
speed = 40,
deleteSpeed = 24,
holdDelay = 2200,
startDelay = 0,
loop,
cursor = true,
static: isStatic = false,
className,
ref,
...props
}: TypewriterProps) {
const textsKey = Array.isArray(text) ? text.join("\u0000") : text;
const texts = React.useMemo(
() => (Array.isArray(text) ? text : [text]),
// eslint-disable-next-line react-hooks/exhaustive-deps
[textsKey],
);
const shouldLoop = loop ?? texts.length > 1;
const reducedMotion = usePrefersReducedMotion();
const skip = isStatic || reducedMotion;
const [phase, setPhase] = React.useState<Phase>("idle");
const [count, setCount] = React.useState(0);
const [textIndex, setTextIndex] = React.useState(0);
const [inView, setInView] = React.useState(false);
const localRef = React.useRef<HTMLSpanElement | null>(null);
const setRefs = (node: HTMLSpanElement | null) => {
localRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
};
// Pause the loop while offscreen (hard rule for anything that runs forever).
React.useEffect(() => {
const el = localRef.current;
if (!el || typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const io = new IntersectionObserver(
(entries) => setInView(entries[0]?.isIntersecting ?? true),
{ rootMargin: "48px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
const current = texts[textIndex % texts.length] ?? "";
const lastText = textIndex >= texts.length - 1;
React.useEffect(() => {
if (skip || !inView) return;
let timeout: ReturnType<typeof setTimeout> | undefined;
if (phase === "idle") {
timeout = setTimeout(() => setPhase("typing"), startDelay);
} else if (phase === "typing") {
if (count < current.length) {
timeout = setTimeout(
() => setCount((c) => Math.min(c + 1, current.length)),
delayAfter(current[count - 1], speed),
);
} else if (shouldLoop || !lastText) {
setPhase("holding");
} else {
setPhase("done");
}
} else if (phase === "holding") {
timeout = setTimeout(() => setPhase("deleting"), holdDelay);
} else if (phase === "deleting") {
if (count > 0) {
timeout = setTimeout(
() => setCount((c) => Math.max(c - 1, 0)),
deleteSpeed,
);
} else {
setTextIndex((i) => (i + 1) % texts.length);
setPhase("typing");
}
}
return () => clearTimeout(timeout);
}, [
phase,
count,
textIndex,
inView,
skip,
current,
lastText,
shouldLoop,
texts.length,
speed,
deleteSpeed,
holdDelay,
startDelay,
]);
const displayed = skip ? current : current.slice(0, count);
const caret = (live: boolean) => (
<span
aria-hidden="true"
data-tw-cursor={live ? "" : undefined}
className={cn(
"pointer-events-none ml-[0.12em] inline-block h-[1em] w-[0.075em] min-w-[1.5px] translate-y-[0.14em] rounded-full bg-current",
!live && "invisible",
)}
/>
);
return (
<>
<style href="paragon-typewriter" precedence="paragon">{`
@keyframes pg-tw-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
[data-tw]:is([data-phase="idle"], [data-phase="holding"], [data-phase="done"]) [data-tw-cursor] {
animation: pg-tw-blink 1s step-end infinite;
}
@media (prefers-reduced-motion: reduce) {
[data-tw] [data-tw-cursor] { animation: none !important; }
}
`}</style>
<span
ref={setRefs}
data-slot="typewriter"
data-tw=""
data-phase={skip ? "done" : phase}
className={cn("inline-grid align-baseline", className)}
{...props}
>
{/* Invisible copies of every string reserve the widest/tallest
footprint, so typing never shifts surrounding layout. */}
{texts.map((t, i) => (
<span
key={i}
aria-hidden="true"
className="invisible col-start-1 row-start-1 whitespace-pre-wrap"
>
{t}
{cursor && caret(false)}
</span>
))}
{/* Screen readers get the full string immediately. */}
<span className="sr-only">{current}</span>
<span
aria-hidden="true"
className="col-start-1 row-start-1 whitespace-pre-wrap"
>
{displayed}
{cursor && !isStatic && caret(true)}
</span>
</span>
</>
);
}