"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { useAnimationLoop } from "@/hooks/use-animation-loop";
export type TeletypeCaret = "block" | "bar" | "underscore" | "none";
export interface TeletypeProps {
lines?: string[];
charsPerSecond?: number;
deleteRate?: number;
hold?: number;
gap?: number;
jitter?: number;
punctuationPause?: number;
caret?: TeletypeCaret;
caretBlink?: number;
loop?: boolean;
reserveSpace?: boolean;
accentColor?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
function makeRandom(seed: number) {
let state = seed >>> 0 || 1;
return () => {
state ^= state << 13;
state >>>= 0;
state ^= state >>> 17;
state ^= state << 5;
state >>>= 0;
return state / 0xffffffff;
};
}
const BREATH = new Set([",", ".", ";", ":", "!", "?", "—", "…"]);
type Phase = "type" | "punct" | "hold" | "delete" | "gap";
export default function Teletype({
lines = [],
charsPerSecond = 22,
deleteRate = 2.4,
hold = 1500,
gap = 420,
jitter = 0.45,
punctuationPause = 220,
caret = "block",
caretBlink = 1.05,
loop = true,
reserveSpace = true,
accentColor = "#a855f7",
paused = false,
reducedMotion = false,
className,
}: TeletypeProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
const [chars, setChars] = useState(0);
const [steady, setSteady] = useState(false);
const [done, setDone] = useState(false);
const halted = paused || reducedMotion;
const text = lines[index] ?? "";
const live = useRef({
lines,
charsPerSecond,
deleteRate,
hold,
gap,
jitter,
punctuationPause,
loop,
reducedMotion,
});
live.current = {
lines,
charsPerSecond,
deleteRate,
hold,
gap,
jitter,
punctuationPause,
loop,
reducedMotion,
};
const fresh = () => ({
index: 0,
chars: 0,
wait: 0,
rate: 1,
phase: "type" as Phase,
breathAt: -1,
random: makeRandom(0x7e1e),
});
const run = useRef(fresh());
useEffect(() => {
if (!reducedMotion) return;
setIndex(0);
setChars(lines[0]?.length ?? 0);
setSteady(true);
setDone(true);
}, [reducedMotion, lines]);
useEffect(() => {
if (reducedMotion) return;
run.current = fresh();
setIndex(0);
setChars(0);
setSteady(false);
setDone(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lines, reducedMotion]);
useEffect(() => {
if (loop) setDone(false);
}, [loop]);
const drawRef = useRef<((dt: number) => void | false) | null>(null);
drawRef.current = (dt: number) => {
const p = live.current;
if (p.lines.length === 0) return false;
if (p.reducedMotion) return false;
const s = run.current;
const line = p.lines[s.index] ?? "";
switch (s.phase) {
case "type": {
s.chars = Math.min(
line.length,
s.chars + p.charsPerSecond * s.rate * dt,
);
const landed = Math.floor(s.chars);
setChars(landed);
if (s.chars >= line.length) {
s.wait = p.hold / 1000;
s.phase = "hold";
setSteady(true);
return;
}
const last = line[landed - 1];
if (landed > s.breathAt && last && BREATH.has(last)) {
s.breathAt = landed;
s.wait = p.punctuationPause / 1000;
s.phase = "punct";
}
return;
}
case "punct": {
s.wait -= dt;
if (s.wait <= 0) s.phase = "type";
return;
}
case "hold": {
s.wait -= dt;
if (s.wait > 0) return;
if (!p.loop && s.index >= p.lines.length - 1) {
setDone(true);
return false;
}
if (p.deleteRate > 0) {
s.phase = "delete";
setSteady(false);
return;
}
s.index = (s.index + 1) % p.lines.length;
s.chars = 0;
s.breathAt = -1;
s.rate = Math.max(0.2, 1 + (s.random() * 2 - 1) * p.jitter);
s.wait = p.gap / 1000;
s.phase = "gap";
setIndex(s.index);
setChars(0);
return;
}
case "delete": {
s.chars = Math.max(
0,
s.chars - p.charsPerSecond * p.deleteRate * s.rate * dt,
);
setChars(Math.floor(s.chars));
if (s.chars > 0) return;
s.index = (s.index + 1) % p.lines.length;
s.breathAt = -1;
s.rate = Math.max(0.2, 1 + (s.random() * 2 - 1) * p.jitter);
s.wait = p.gap / 1000;
s.phase = "gap";
setIndex(s.index);
setSteady(true);
return;
}
case "gap": {
s.wait -= dt;
if (s.wait <= 0) {
s.phase = "type";
setSteady(false);
}
return;
}
}
};
const runtime = useAnimationLoop({
target: rootRef,
halted,
onFrame: ({ dt }) => (drawRef.current ? drawRef.current(dt) : false),
});
useEffect(() => {
if (!halted && !done) runtime.start();
}, [halted, done, lines, loop, runtime]);
const visible = text.slice(0, chars);
const blinking = !halted && steady && caretBlink > 0;
const caretBox =
caret === "bar"
? "w-[2px] h-[1.1em] translate-y-[0.18em]"
: caret === "underscore"
? "w-[0.62em] h-[2px] translate-y-[0.05em]"
: "w-[0.5em] h-[1em] translate-y-[0.14em]";
const ghosts = useMemo(() => lines.slice(0, 24), [lines]);
return (
<div
ref={rootRef}
className={cn(
"grid w-full font-mono text-ink *:col-1 *:row-1",
className,
)}
>
{reserveSpace
? ghosts.map((line, i) => (
<span
key={i}
aria-hidden
className="pointer-events-none invisible whitespace-pre-wrap select-none"
>
{line}
</span>
))
: null}
<p aria-hidden className="whitespace-pre-wrap">
{visible}
{caret === "none" ? null : (
<motion.span
className={cn("ml-px inline-block align-baseline", caretBox)}
style={{ backgroundColor: accentColor }}
animate={blinking ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }}
transition={
blinking
? { duration: caretBlink, repeat: Infinity, ease: "linear" }
: { duration: 0 }
}
/>
)}
</p>
<span className="sr-only">{text}</span>
</div>
);
}