"use client"; // Adapted from Kokonut UI (@dorianbaffier), MIT. See THIRD_PARTY_NOTICES.md. import { useEffect, useState, type ComponentProps } from "react"; export interface TypeWriterProps extends Omit< ComponentProps<"span">, "children" > { text: string; /** Milliseconds per grapheme. Animation runs once, capped at five seconds. */ speed?: number; animate?: boolean; } export function TypeWriter({ text, speed = 45, animate = true, className = "", ...props }: TypeWriterProps) { const [visible, setVisible] = useState(text); useEffect(() => { const media = matchMedia("(prefers-reduced-motion: reduce)"); let timer: ReturnType; const run = () => { clearTimeout(timer); if (!animate || media.matches) { setVisible(text); return; } const letters = [ ...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment( text, ), ].map((part) => part.segment); setVisible(""); const delay = Math.min( Number.isFinite(speed) ? Math.max(1, speed) : 45, 5000 / Math.max(1, letters.length), ); const started = performance.now(); const tick = () => { const elapsed = performance.now() - started; const count = elapsed >= 5000 ? letters.length : Math.floor(elapsed / delay); setVisible(letters.slice(0, count).join("")); if (count < letters.length) timer = setTimeout(tick, Math.max(16, delay)); }; timer = setTimeout(tick, Math.max(16, delay)); }; run(); media.addEventListener("change", run); return () => { clearTimeout(timer); media.removeEventListener("change", run); }; }, [text, speed, animate]); return ( {text} ); }