reelcn
Product

Product

Terminal

Terminal window that types commands, prints their output line by line and scrolls as it fills.

Install

Usage

<Center>
  <Terminal
    lines={[
      { type: "command", text: "npx shadcn@latest add ./r/terminal.json" },
      { type: "output", text: "Created 3 files" },
    ]}
  />
</Center>

Props

NameTypeDefaultDescription
delay?numberFrames to wait before entering.
duration?numberEnter duration in frames. Defaults to 0.6s.
exit?number | booleanExit at the end of the parent `<Sequence>`. `false` keeps it on screen, a number sets the exit length in frames.
motion?MotionPresetOverride the theme's motion personality.
linesTerminalLine[]
title?string"Terminal"Window title.
prompt?string"$"Printed before every command.
cps?number32Command typing speed in characters per second.
pause?numberFrames the prompt waits before a command starts typing. Defaults to 0.4s.
outputDelay?numberFrames between pressing enter and the first output line. Defaults to 0.3s.
outputStagger?number3Frames between output lines.
highlight?booleantrueColor commands with the bash tokenizer.
rows?numberVisible rows; older lines scroll out the top. Defaults to every line, capped to fit the safe area.
fontSize?number28Largest font size in design units. It shrinks so the longest line fits.
width?numberWindow width as a fraction of the safe area (0–1). Defaults to 0.62 in landscape, 1 otherwise.
background?stringTerminal background. Defaults to a shade between the theme surface and background.
borderColor?string
promptColor?stringPrompt color. Defaults to the theme accent.
outputColor?stringOutput text color. Defaults to a softened foreground.
radius?numberCorner radius in design units. Defaults to 60% of the theme radius.
style?CSSProperties
className?string

Use

  • Install steps, CLI demos and deploy logs in product and tutorial videos
  • Any sequence of shell commands with their output

Avoid

  • Source files or snippets without a prompt — use code-block

Dependencies

Source

registry/items/terminal.tsx
/**
 * @title Terminal
 * @category product
 * @description Terminal window that types commands, prints their output line by line and scrolls as it fills.
 * @duration data-driven
 * @use Install steps, CLI demos and deploy logs in product and tutorial videos
 * @use Any sequence of shell commands with their output
 * @avoid Source files or snippets without a prompt — use `code-block`
 * @tags terminal, shell, cli, command, console, install
 * @example
 * <Center>
 *   <Terminal
 *     lines={[
 *       { type: "command", text: "npx shadcn@latest add ./r/terminal.json" },
 *       { type: "output", text: "Created 3 files" },
 *     ]}
 *   />
 * </Center>
 */
import type React from "react";
import { type Token, tokenColors, tokenize } from "./code-tokens";
import { alpha, type MotionProps, tween, useMotion, useTheme, useViewport } from "./core";

export type TerminalLine = { type: "command"; text: string } | { type: "output"; text: string };

export type TerminalProps = MotionProps & {
  lines: TerminalLine[];
  /** Window title. */
  title?: string;
  /** Printed before every command. */
  prompt?: string;
  /** Command typing speed in characters per second. */
  cps?: number;
  /** Frames the prompt waits before a command starts typing. Defaults to 0.4s. */
  pause?: number;
  /** Frames between pressing enter and the first output line. Defaults to 0.3s. */
  outputDelay?: number;
  /** Frames between output lines. */
  outputStagger?: number;
  /** Color commands with the bash tokenizer. */
  highlight?: boolean;
  /** Visible rows; older lines scroll out the top. Defaults to every line, capped to fit the safe area. */
  rows?: number;
  /** Largest font size in design units. It shrinks so the longest line fits. */
  fontSize?: number;
  /** Window width as a fraction of the safe area (0–1). Defaults to 0.62 in landscape, 1 otherwise. */
  width?: number;
  /** Terminal background. Defaults to a shade between the theme surface and background. */
  background?: string;
  borderColor?: string;
  /** Prompt color. Defaults to the theme accent. */
  promptColor?: string;
  /** Output text color. Defaults to a softened foreground. */
  outputColor?: string;
  /** Corner radius in design units. Defaults to 60% of the theme radius. */
  radius?: number;
  style?: React.CSSProperties;
  className?: string;
};

/** Advance width of one monospace glyph, in em. */
const CHAR = 0.6;
const LINE = 1.55;

type Row = { kind: "command" | "output" | "prompt"; text: string; at: number; typeFrom: number };

export function Terminal({
  lines,
  title = "Terminal",
  prompt = "$",
  cps = 32,
  pause,
  outputDelay,
  outputStagger = 3,
  highlight = true,
  rows: rowsProp,
  fontSize = 28,
  width: widthFraction,
  background,
  borderColor,
  promptColor,
  outputColor,
  radius,
  style,
  className,
  ...motion
}: TerminalProps) {
  const theme = useTheme();
  const { u, width, height, safe, isLandscape } = useViewport();
  const m = useMotion(motion);
  const palette = tokenColors(theme.colors);
  const border = borderColor ?? theme.colors.border;
  const waitFrames = pause ?? Math.round(m.fps * 0.4);
  const enterFrames = outputDelay ?? Math.round(m.fps * 0.3);

  // Schedule every row: a command's prompt appears, waits, types, then its output prints line by line.
  const timeline: Row[] = [];
  let t = m.delay + Math.round(m.enterFrames * 0.5);
  for (const line of lines) {
    if (line.type === "command") {
      timeline.push({ kind: "command", text: line.text, at: t, typeFrom: t + waitFrames });
      t += waitFrames + Math.ceil((line.text.length * m.fps) / cps) + enterFrames;
    } else {
      timeline.push({ kind: "output", text: line.text, at: t, typeFrom: t });
      t += outputStagger;
    }
  }
  timeline.push({ kind: "prompt", text: "", at: t, typeFrom: t });

  // Layout: the font shrinks until the widest line fits; the window keeps a fixed number of rows.
  const titleH = u(52);
  const padX = u(30);
  const padY = u(24);
  const maxW = (width - safe.x * 2) * (widthFraction ?? (isLandscape ? 0.62 : 1));
  const maxH = height - safe.top - safe.bottom;
  const longest = Math.max(
    12,
    ...timeline.map((row) => (row.kind === "output" ? row.text.length : prompt.length + 1 + row.text.length + 1)),
  );
  const fontPx = Math.min(u(fontSize), (maxW - padX * 2) / (longest * CHAR));
  const lineH = fontPx * LINE;
  const fitRows = Math.max(1, Math.floor((maxH - titleH - padY * 2) / lineH));
  const rowCount = Math.min(rowsProp ?? timeline.length, fitRows);

  // Rows fade in over a few frames and the view scrolls smoothly once they overflow.
  const appear = (at: number) => Math.min(1, tween(m.frame, m.fps, { from: at, duration: 4, motion: "smooth" }));
  const visible = timeline.filter((row) => m.frame >= row.at);
  const filled = timeline.reduce((sum, row) => sum + appear(row.at), 0);
  const scroll = Math.max(0, filled - rowCount) * lineH;
  const active = visible[visible.length - 1];
  const blinkOn = Math.floor(m.frame / Math.round(m.fps * 0.5)) % 2 === 0;

  const renderRow = (row: Row, index: number) => {
    if (row.kind === "output") {
      return (
        <span
          style={{ color: outputColor ?? `color-mix(in srgb, ${theme.colors.foreground} 72%, ${theme.colors.muted})` }}
        >
          {row.text}
        </span>
      );
    }
    const typedChars = Math.max(0, Math.min(row.text.length, Math.floor(((m.frame - row.typeFrom) * cps) / m.fps)));
    const typing = m.frame >= row.typeFrom && typedChars < row.text.length;
    const tokens: Token[] = highlight
      ? tokenize(row.text.slice(0, typedChars), "bash")[0]
      : [{ text: row.text.slice(0, typedChars), kind: "plain" }];
    const caret = row === active && (typing || blinkOn);
    return (
      <>
        <span style={{ color: promptColor ?? theme.colors.accent }}>{prompt} </span>
        {tokens.map((token, k) => (
          <span key={`${index}-${k}`} style={{ color: palette[token.kind] }}>
            {token.text}
          </span>
        ))}
        {caret && (
          <span
            style={{
              display: "inline-block",
              width: "0.6em",
              height: "1.15em",
              verticalAlign: "-0.2em",
              background: alpha(theme.colors.foreground, 0.85),
            }}
          />
        )}
      </>
    );
  };

  return (
    <div
      className={className}
      style={{
        width: maxW,
        borderRadius: u(radius ?? theme.radius * 0.6),
        overflow: "hidden",
        background: background ?? `color-mix(in srgb, ${theme.colors.surface} 55%, ${theme.colors.background})`,
        border: `1px solid ${border}`,
        boxShadow: `0 ${u(2)}px ${u(6)}px ${alpha("#000000", 0.12)}, 0 ${u(28)}px ${u(72)}px ${alpha("#000000", 0.3)}`,
        opacity: Math.min(1, Math.max(0, m.enter)) * (1 - m.exit),
        translate: `0 ${(1 - m.enter) * u(40) - m.exit * u(24)}px`,
        scale: String(0.97 + 0.03 * m.enter),
        ...style,
      }}
    >
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "1fr auto 1fr",
          alignItems: "center",
          height: titleH,
          padding: `0 ${u(22)}px`,
          background: theme.colors.surface,
          borderBottom: `1px solid ${border}`,
          fontFamily: theme.fonts.body,
          fontSize: u(20),
          fontWeight: 500,
          color: theme.colors.muted,
        }}
      >
        <div style={{ display: "flex", gap: u(9) }}>
          {[0, 1, 2].map((dot) => (
            <div
              key={dot}
              style={{ width: u(13), height: u(13), borderRadius: "50%", background: alpha(theme.colors.muted, 0.4) }}
            />
          ))}
        </div>
        <div>{title}</div>
      </div>
      <div
        style={{
          height: rowCount * lineH + padY * 2,
          padding: `${padY}px ${padX}px`,
          overflow: "hidden",
          fontFamily: theme.fonts.mono,
          fontSize: fontPx,
          lineHeight: `${lineH}px`,
          color: theme.colors.foreground,
        }}
      >
        <div style={{ translate: `0 ${-scroll}px` }}>
          {visible.map((row, index) => (
            <div key={index} style={{ height: lineH, whiteSpace: "pre", overflow: "hidden", opacity: appear(row.at) }}>
              {renderRow(row, index)}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Related