Display

Terminal

A terminal window that replays a scripted session — commands type out character by character, output blocks fade in, comments stay dim.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Block reveal for output lines + a step-timed caret blink. React 19 hoisted style. */
const KEYFRAMES = `@keyframes tm-line-in{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}
@keyframes tm-caret{0%,49%{opacity:1}50%,100%{opacity:0}}`

/** Under prefers-reduced-motion the caret stays lit instead of blinking. */
const CARET_CLASS =
  "ml-0.5 inline-block h-[1em] w-[0.5em] translate-y-[0.15em] bg-primary [animation:tm-caret_1s_step-end_infinite] motion-reduce:[animation:none]"

export type TerminalLineType = "command" | "output" | "comment"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/terminal.json

Prompt

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; lines is 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 command types character by character because that is the part a viewer mentally "performs", while output and comment land as whole blocks; typing machine output would read as fake.
  • One pending timer — the scheduler chains a single setTimeout per step instead of running an interval per line, so cleanup is one clearTimeout and an unmount mid-typing can't leak a tail of pending callbacks.
  • Derived finished state — with typing={false} or prefers-reduced-motion the "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-hidden and paired with an sr-only copy of the full session, which is the only way a per-character animation can exist without a screen reader narrating each keystroke; the aria-hidden stops at that pane, so the scroll box around it can stay focusable for keyboard users when maxHeight clips the script.

On This Page