Anatomy of Terminal
How a count/typed state machine types commands character-by-character, gates playback with IntersectionObserver, and loops after a two-second pause.
Terminal is a small state machine dressed as a window. Commands type
out one character at a time; output and comment lines appear after a
beat. An optional chrome bar with traffic lights frames the mono body,
and a pulsing caret marks the active line.
Chrome, body, line roles
showChrome (default true) renders the header — three dots, optional
centered title, and a balancing spacer. The body is
font-mono text-[13px] with ligatures off so glyphs don't shift as they
type. Each line is command, output, or comment, each with its own
foreground opacity.
- Chrome
- showChrome — traffic lights + optional title
- Body
- mono 13px, space-y-1, ligatures off
- Lines
- command · output · comment color roles
{showChrome ? (
<div className={HEADER_BASE}>
<div className="flex gap-1.5" aria-hidden>
<span className="size-3 rounded-full bg-red-500/80" />
<span className="size-3 rounded-full bg-yellow-500/80" />
<span className="size-3 rounded-full bg-green-500/80" />
</div>
{title ? <span className="…">{title}</span> : null}
</div>
) : null}The typing state machine
Two pieces of state drive the script: count (fully finished lines) and
typed (characters revealed on the current command). While
typed < line.text.length on a command, a timeout bumps typed every
typingSpeed ms (default 38). Then a pause (320 after a command,
line.delay ?? 380 otherwise) advances count and resets typed.
setTyped(t => t + 1) · then setCount(c => c + 1)
- Command
- typed++ every typingSpeed ms (38)
- Output
- lands after delay (default 380ms)
- Caret
- animate-pulse on the active command
if (isCommand && typed < line.text.length) {
const id = window.setTimeout(() => setTyped((t) => t + 1), typingSpeed);
return () => window.clearTimeout(id);
}
const pause = isCommand ? 320 : (line.delay ?? 380);
const id = window.setTimeout(() => {
setCount((c) => c + 1);
setTyped(0);
}, pause);The caret is a primary-colored block with animate-pulse, shown on the
active command — or on the last line when the script is done and
loop is false.
Start on view, loop, reduce
Playback stays cold until the terminal crosses an
IntersectionObserver at threshold: 0.35 (when startOnView is
true). Once active, finishing the script with loop waits 2s, then
resets count and typed to zero. Under prefers-reduced-motion, the
machine skips typing entirely and dumps lines.length in one shot —
caret off, full transcript still in sr-only for assistive tech.
viewport · threshold 0.35
- startOnView
- IO threshold 0.35 → setActive(true), disconnect
- Loop
- 2s pause, then count=0 / typed=0
- Reduced
- dump lines.length immediately, no caret
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setActive(true);
io.disconnect();
}
},
{ threshold: 0.35 },
);
// loop restart
window.setTimeout(() => {
setCount(0);
setTyped(0);
}, 2000);
// reduced
if (reduced && active) {
setCount(lines.length);
setTyped(0);
}Accessibility
The animated view is aria-hidden. A parallel sr-only block always
exposes the full script — prompt + text for commands, plain text for
the rest — so screen readers never wait on the typewriter.
The result
A count/typed machine, a 0.35 IO gate, a 2s loop pause, and a caret that only blinks when motion is welcome.