How to Build an Animated Terminal in React
By Hizmedia · · 6 min read
A terminal that plays itself is the most convincing thing on a developer tool's landing page: people see the exact commands and the exact output before they install anything. Building one well is mostly about timing and restraint. This guide walks through the approach behind MotionKit's Animated Terminal: describe the session as data, advance it with one timer at a time, type with a rhythm that feels human, and give screen readers the whole transcript at once.
Describe the session as data
Hard-coding JSX for every line makes a terminal impossible to change. Instead, describe it as a list of three kinds of line: commands that type out, output that prints at once, and spinners that run for a while and then settle.
export type TerminalLine =
| { command: string }
| { output: string; tone?: "muted" | "success" | "error" }
| { spinner: string; done: string; duration?: number };A union like this keeps the rendering simple: check which key a line has and draw it accordingly. Changing the session is then just editing an array.
Advance it with one timer at a time
Two numbers describe where the session is: how many lines have finished, and how many characters of the current command have been typed. One effect looks at those numbers, schedules a single timeout for the next step, and clears it on cleanup. There's never more than one timer, so nothing races.
useEffect(() => {
if (!inView || finished) return;
const line = lines[shown];
let wait = 90;
let step = () => { setShown((n) => n + 1); setChars(0); };
if ("command" in line && chars < line.command.length) {
wait = typeSpeed;
step = () => setChars((n) => n + 1);
} else if ("command" in line) wait = 380;
else if ("spinner" in line) wait = (line.duration ?? 1.2) * 1000;
const timer = window.setTimeout(step, wait);
return () => window.clearTimeout(timer);
}, [inView, finished, lines, shown, chars, typeSpeed]);The state only changes inside the timeout, never in the effect body, which keeps React's rules happy. Replaying is setting both numbers back to zero.
Type like a person, the same way every time
A fixed delay per character looks robotic. Random delays look better, but they differ between runs and between the server and the browser. An integer hash of the line and character position gives you both: an uneven, human rhythm that is identical on every replay.
wait = typeSpeed * (0.5 + ((chars * 7919 + shown * 104729) % 11) / 10);A slightly longer pause after each command finishes, as if someone pressed Enter, sells the effect more than any amount of tuning per character.
Start when it's seen
A terminal that finished playing before anyone scrolled to it has wasted its moment. Motion's useInView with once: true starts the session the first time the terminal is properly on screen, and the effect above simply waits until then.
Accessibility
- Screen readers shouldn't hear a transcript typed one character at a time. Hide the animated lines with
aria-hiddenand put the complete session in a visually hidden<pre>. - With reduced motion on, show the finished session straight away and stop the caret blinking.
- Keep output readable: muted text should still reach 4.5:1 against the terminal's background. MotionKit's measures 7.7:1.
- If you offer a replay button, make it a real
<button>so it works from the keyboard.