"use client";
import * as React from "react";
export type TypewriterCaret = "bar" | "block" | "underscore";
export interface TypewriterProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** Phrases typed one after another. */
words: string[];
/** Static text before the animated phrase, e.g. "We design". */
prefix?: string;
/** Element to render. */
as?: "span" | "p" | "h1" | "h2" | "h3" | "div";
/** Time per typed character, in ms. */
typeSpeed?: number;
/** Time per deleted character, in ms. */
deleteSpeed?: number;
/** Hold time once a phrase is complete, in ms. */
pause?: number;
/** Cycle forever. When false, typing stops on the last phrase. */
loop?: boolean;
/** Caret shape. */
caret?: TypewriterCaret;
/** Vary keystroke timing slightly so it feels typed by a person. */
jitter?: boolean;
/** Reserve the width of the longest phrase so surrounding layout never shifts. */
reserveWidth?: boolean;
/** Caret colour (any CSS colour). */
accent?: string;
/** Text for assistive technology. Defaults to the prefix followed by every phrase. */
srText?: string;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* --------------------------------- caret ---------------------------------- */
const CARET: Record<TypewriterCaret, string> = {
bar: "ml-[0.06em] h-[1.05em] w-[0.07em] min-w-[2px] translate-y-[0.2em] rounded-full",
block: "ml-[0.08em] h-[1.05em] w-[0.55em] translate-y-[0.2em] rounded-[0.08em]",
underscore: "ml-[0.06em] h-[0.09em] min-h-[2px] w-[0.55em] rounded-full",
};
// Unit separator: never part of real phrases, so the joined key round-trips safely.
const SEP = "␟";
type Phase = "typing" | "holding" | "deleting" | "done";
/**
* Types, holds and deletes a rotating list of phrases. Characters are written straight into the
* DOM from a timer chain (no React render per keystroke); the loop pauses offscreen and in hidden tabs.
*/
export function Typewriter({
words,
prefix,
as = "span",
typeSpeed = 70,
deleteSpeed = 35,
pause = 1600,
loop = true,
caret = "bar",
jitter = true,
reserveWidth = false,
accent = "#FF7448",
srText,
className = "",
style,
...props
}: TypewriterProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const textRef = React.useRef<HTMLSpanElement>(null);
const caretRef = React.useRef<HTMLSpanElement>(null);
const Tag = as as React.ElementType;
// A stable primitive key, so inline `words={[...]}` arrays don't restart the loop every render.
const wordsKey = words.join(SEP);
React.useLayoutEffect(() => {
const root = rootRef.current;
const textNode = textRef.current;
const caretNode = caretRef.current;
if (!root || !textNode) return;
const phrases = wordsKey ? wordsKey.split(SEP).map((w) => Array.from(w)) : [];
if (!phrases.length) {
textNode.textContent = "";
return;
}
// Blink only while idle (holding / done); a typing caret stays solid like a real editor.
const blink =
caretNode && typeof caretNode.animate === "function" && !reduceMotion
? caretNode.animate(
[
{ opacity: 1, offset: 0 },
{ opacity: 1, offset: 0.5 },
{ opacity: 0, offset: 0.5 },
{ opacity: 0, offset: 1 },
],
{ duration: 1000, iterations: Infinity },
)
: null;
const setBlink = (on: boolean) => {
if (!blink) return;
if (on) blink.play();
else {
blink.pause();
blink.currentTime = 0;
}
};
if (reduceMotion) {
textNode.textContent = phrases[0].join("");
return;
}
let wordIndex = 0;
let charCount = 0;
let phase: Phase = "typing";
let timer = 0;
let inView = false;
let running = false;
textNode.textContent = "";
setBlink(false);
const humanize = (base: number, char?: string) => {
if (!jitter) return base;
// ±40% per keystroke, plus a short beat after spaces and punctuation.
const beat = char && /[\s,.;:!?]/.test(char) ? base * 0.9 : 0;
return base * (0.6 + Math.random() * 0.8) + beat;
};
const schedule = (ms: number) => {
window.clearTimeout(timer);
timer = window.setTimeout(step, Math.max(0, ms));
};
function step() {
const word = phrases[wordIndex];
if (phase === "typing") {
if (charCount < word.length) {
charCount += 1;
textNode!.textContent = word.slice(0, charCount).join("");
schedule(humanize(typeSpeed, word[charCount - 1]));
return;
}
const isLast = wordIndex === phrases.length - 1;
phase = !loop && isLast ? "done" : "holding";
setBlink(true);
if (phase === "holding") schedule(pause);
return;
}
if (phase === "holding") {
phase = "deleting";
setBlink(false);
schedule(deleteSpeed);
return;
}
if (phase === "deleting") {
if (charCount > 0) {
charCount -= 1;
textNode!.textContent = word.slice(0, charCount).join("");
schedule(humanize(deleteSpeed));
return;
}
wordIndex = (wordIndex + 1) % phrases.length;
phase = "typing";
schedule(Math.max(typeSpeed * 3, 180));
}
}
const sync = () => {
const shouldRun = inView && !document.hidden;
if (shouldRun === running) return;
running = shouldRun;
if (running) {
if (phase === "holding" || phase === "done") setBlink(true);
if (phase !== "done") schedule(phase === "holding" ? pause : typeSpeed);
} else {
window.clearTimeout(timer);
blink?.pause();
}
};
const observer = new IntersectionObserver((entries) => {
inView = entries.some((entry) => entry.isIntersecting);
sync();
});
observer.observe(root);
document.addEventListener("visibilitychange", sync);
return () => {
window.clearTimeout(timer);
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
blink?.cancel();
};
// reserveWidth swaps the DOM nodes the loop writes into, so it restarts the loop too.
}, [wordsKey, typeSpeed, deleteSpeed, pause, loop, jitter, reduceMotion, reserveWidth]);
const spoken = srText ?? [prefix, words.join(", ")].filter(Boolean).join(" ");
const caretNode = (
<span
ref={caretRef}
className={`inline-block shrink-0 bg-[var(--typewriter-accent)] align-baseline ${CARET[caret] ?? CARET.bar}`}
/>
);
return (
<Tag
ref={rootRef}
className={className}
style={{ ["--typewriter-accent" as string]: accent, ...style }}
{...props}
>
<span className="sr-only">{spoken}</span>
<span aria-hidden="true">
{prefix ? <>{prefix} </> : null}
{reserveWidth ? (
// Every phrase sits invisibly in the same grid cell, so the box is as wide as the longest one.
<span className="inline-grid whitespace-pre align-bottom">
{words.map((word, i) => (
<span key={i} className="invisible col-start-1 row-start-1">
{word}
<span className={`inline-block ${CARET[caret] ?? CARET.bar}`} />
</span>
))}
<span className="col-start-1 row-start-1 justify-self-start">
<span ref={textRef} />
{caretNode}
</span>
</span>
) : (
<span className="whitespace-pre-wrap">
<span ref={textRef} />
{caretNode}
</span>
)}
</span>
</Tag>
);
}