Terminal
A terminal window that replays a scripted session — commands type out character by character, output blocks fade in, comments stay dim.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/terminal.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Terminal" component that replays a
scripted shell session. Zero runtime dependencies beyond React.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement> with
children omitted — the transcript is the content.
- lines: { type: "command" | "output" | "comment"; text: string; delay?: number }[]
is the script, played in array order; delay adds a pause (ms) before that
line starts.
- typing?: boolean (default true) — false renders the finished transcript at
once. speed?: number (default 28, ms per character), linePause?: number
(default 420, ms of silence between lines), loop?: boolean (default false),
loopDelay?: number (default 2000, ms the finished transcript rests before
rewinding), prompt?: string (default "$"), title?: string (title-bar label),
maxHeight?: number | string (caps the scrolling viewport).
className merges onto the root via cn().
Behavior
- Playback state is a single cursor: { line, chars } — lines before `line` are
fully on screen, the line at `line` shows text.slice(0, chars), -1 means
nothing yet and lines.length means finished. One derived value, no per-line
bookkeeping.
- A chained setTimeout drives everything, and there is only ever ONE pending
timer: a command line schedules the next character every `speed` ms, an
output/comment line lands whole and schedules `linePause`, and the last line
either stops or (loop) waits loopDelay and rewinds. The effect's cleanup
clears that single timer plus the requestAnimationFrame that kicks the chain
off, so unmounting mid-typing leaves nothing running.
- Start the rewind + first step inside a requestAnimationFrame rather than
synchronously in the effect body, so a prop change never cascades an extra
render pass.
- typing={false} and prefers-reduced-motion (read through useSyncExternalStore
on matchMedia) both take the same path: the finished cursor is DERIVED in
render instead of stored, so the whole transcript is on screen without any
effect having to run. The component is fully functional with the animation
off — the animation only decides when text appears, never whether it does.
- Auto-follow: after each cursor change set scrollTop = scrollHeight on the
transcript element only, so a long script keeps its newest line visible
without ever scrolling the page.
- Consumers pass an inline array literal almost every time. Derive a content
signature from lines and keep a snapshot in state (React's "adjust state
during render" pattern), so identity churn on a parent re-render doesn't
restart playback from the top — only a real content change does.
Rendering & styling
- Window: rounded-xl border bg-card font-mono text-sm overflow-hidden. Title
bar: bg-muted with a bottom border, three size-3 dots (bg-destructive/70,
var(--chart-4), var(--chart-2)) and an optional centred, truncated
text-muted-foreground title.
- Lines: prompt glyph in text-primary (shrink-0, select-none), command text in
text-foreground, output in text-muted-foreground, comment in
text-muted-foreground italic — italic is what separates a comment from an
output line, because fading the token further (/70) drops the text under 3:1
on a light card. Rows are flex gap-2 with break-all and
whitespace-pre-wrap so indentation survives and a 200-character hash wraps
instead of stretching the window.
- Caret: an inline-block span (h-[1em] w-[0.5em]) in bg-primary on a
1s step-end blink keyframe, rendered after the characters typed so far, and
parked on a resting prompt line once the script finishes. Gate the resting
caret on the `typing` prop rather than on the scheduler: under
prefers-reduced-motion the transcript lands whole but the caret still parks
there, with the keyframe dropped so it stays lit instead of blinking.
typing={false} is a deliberate static snapshot and shows no caret at all.
- Output/comment lines fade in with a short hoisted @keyframes; both keyframes
ship inside the component via a React 19 <style href precedence> tag, so no
Tailwind config edits and duplicate terminals dedupe to one style tag.
- Accessibility: the animated transcript is aria-hidden decoration and the root
is aria-live="off"; the real content is a sr-only copy of every line
(commands prefixed with the prompt) rendered once, so a screen reader reads
the finished session instead of announcing it character by character. Put
that aria-hidden on the inner playback layer, NOT on the scroll box: when
maxHeight clips the transcript the scroll box takes tabIndex={0} plus a
role/aria-label so a keyboard user can reach the lines below the fold, and a
focusable element must never be aria-hidden. Its focus ring is inset
(focus-visible:ring-inset) because the window clips overflow.
Customization levers
- Pacing: speed (18-45ms reads as "a person typing"), linePause and per-line
delay are independent — use delay to fake a slow install step without
slowing the typing itself.
- Line vocabulary: the three types map to three class strings. Add a "success"
or "error" type by adding one entry to that mapping (text-primary /
text-destructive) — the scheduler treats any non-command line as a block
reveal, so nothing else changes.
- Chrome: drop the title bar for a bare code-block look, or keep the dots and
swap the token trio for a monochrome set (bg-muted-foreground/40) when the
traffic lights feel too playful.
- Height: maxHeight turns the window into a scrolling viewport (and, with it, a
keyboard-reachable tab stop); leave it off and the window grows to fit the
whole script and needs no tab stop. Because it grows as lines land,
reserve the finished height with a min-h-* class on a landing page so the
section around it doesn't reflow mid-playback.
- Loop: loop + loopDelay make it an ambient hero animation; leave loop off for
a one-shot demo, and gate mounting on an IntersectionObserver if you only
want it to run when scrolled into view.
- Prompt: any string — "$", "›", "PS>", "user@host:~$" — it is rendered as-is
in front of every command line and reused in the sr-only transcript.Concepts
- Scripted transcript — the component owns no shell;
linesis a screenplay whose order, pauses and line kinds you author, which is why the same window can play an install, a build log or a git session. - Per-kind reveal — a
commandtypes character by character because that is the part a viewer mentally "performs", whileoutputandcommentland as whole blocks; typing machine output would read as fake. - One pending timer — the scheduler chains a single
setTimeoutper step instead of running an interval per line, so cleanup is oneclearTimeoutand an unmount mid-typing can't leak a tail of pending callbacks. - Derived finished state — with
typing={false}orprefers-reduced-motionthe "everything is on screen" cursor is computed during render rather than written by an effect, so the transcript is complete on the very first paint and the animation stays a pure garnish. - Content-addressed script — playback keys off a signature of the lines' content, not the array's identity, so a parent re-render passing a fresh literal doesn't yank the session back to the top.
- Decorative playback, readable content — the animating pane is
aria-hiddenand paired with ansr-onlycopy of the full session, which is the only way a per-character animation can exist without a screen reader narrating each keystroke; thearia-hiddenstops at that pane, so the scroll box around it can stay focusable for keyboard users whenmaxHeightclips the script.