Workspace / component / text
Typewriter Text
paxfx add blocks/component-text-typewriterlocal onlyTier: low. Reduced motion: Full phrase renders instantly with a steady caret.
"use client";
import { useEffect, useMemo, useState } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";
export interface TypewriterTextProps {
/** Phrases typed in order. With more than one, each is erased and retyped. */
phrases?: string[];
/** Heading level or inline element to render. */
as?: "h1" | "h2" | "h3" | "p" | "span";
/** Base milliseconds per typed character. */
typeMs?: number;
/** Milliseconds per erased character. */
eraseMs?: number;
/** Milliseconds a completed phrase rests before erasing. */
holdMs?: number;
className?: string;
}
const DEFAULT_PHRASES = ["Design once.", "Ship everywhere.", "Stay fast."];
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Types characters at a natural cadence with a blinking block caret, then
* rests. Cadence jitter comes from a seeded PRNG so every run reads the
* same. A single phrase types once and rests; multiple phrases erase and
* retype on a loop driven by one rAF that cancels on unmount.
*/
export function TypewriterText({
phrases = DEFAULT_PHRASES,
as: Tag = "h2",
typeMs = 62,
eraseMs = 30,
holdMs = 1700,
className,
}: TypewriterTextProps) {
const reduced = useReducedMotion();
const [ref, inView] = useInView<HTMLSpanElement>({ threshold: 0.4 });
const [display, setDisplay] = useState("");
const longest = useMemo(
() => phrases.reduce((a, b) => (b.length > a.length ? b : a), ""),
[phrases],
);
useEffect(() => {
if (reduced) {
setDisplay(phrases[0] ?? "");
return;
}
if (!inView) return;
let raf = 0;
let last = performance.now();
let wait = 0;
let phraseIdx = 0;
let charCount = 0;
let phase: "typing" | "holding" | "erasing" = "typing";
const rand = mulberry32(0x7ad5f2c3);
const nextDelay = () => typeMs * (0.7 + rand() * 0.7);
let delay = nextDelay();
const step = (now: number) => {
raf = requestAnimationFrame(step);
wait += Math.min(now - last, 100);
last = now;
const phrase = phrases[phraseIdx] ?? "";
if (phase === "typing") {
while (wait >= delay && charCount < phrase.length) {
wait -= delay;
charCount += 1;
delay = nextDelay();
}
if (charCount >= phrase.length) {
if (phrases.length <= 1) {
setDisplay(phrase);
cancelAnimationFrame(raf);
return;
}
phase = "holding";
wait = 0;
}
} else if (phase === "holding") {
if (wait >= holdMs) {
phase = "erasing";
wait = 0;
}
} else {
while (wait >= eraseMs && charCount > 0) {
wait -= eraseMs;
charCount -= 1;
}
if (charCount === 0) {
phraseIdx = (phraseIdx + 1) % phrases.length;
phase = "typing";
wait = 0;
delay = nextDelay();
}
}
setDisplay(phrase.slice(0, charCount));
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [inView, reduced, phrases, typeMs, eraseMs, holdMs]);
return (
<Tag
className={cn("m-0 font-semibold tracking-tight", className)}
data-pax-asset="component-text-typewriter"
>
<style>{`
@keyframes pax-typewriter-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
`}</style>
<span className="sr-only">{phrases.join(" ")}</span>
<span ref={ref} aria-hidden="true" className="inline-grid">
<span className="invisible col-start-1 row-start-1 whitespace-pre">{`${longest}\u00A0`}</span>
<span className="col-start-1 row-start-1 whitespace-pre">
{display}
<span
className="ml-[0.08em] inline-block h-[1em] w-[0.5ch] translate-y-[0.12em] bg-current"
style={
reduced ? undefined : { animation: "pax-typewriter-blink 1.06s steps(1) infinite" }
}
/>
</span>
</span>
</Tag>
);
}