Terminal
A terminal window that types commands and streams output, with replay and copy.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/terminal.json~/northwind
❯
components/examples/terminal-demo.tsx
"use client";
import { Terminal } from "@/components/ui/terminal";
export default function TerminalDemo() {
return (
<Terminal
className="w-full max-w-xl"
title="~/northwind"
lines={[
{ type: "command", text: "npx shadcn@latest add @lode/data-table" },
{ type: "progress", text: "Checking registry", ms: 600 },
{ type: "progress", text: "Installing motion, lucide-react", ms: 900 },
{ type: "output", text: "Created components/ui/data-table.tsx", tone: "success" },
{ type: "command", text: "npm run dev" },
{ type: "output", text: "▲ Ready on http://localhost:3000", tone: "muted" },
]}
/>
);
}When to use
- Install instructions and CLI demos that type themselves out.
When not to use
- A real shell: use xterm.js.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/terminal.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/terminal.tsx"use client"; import * as React from "react"; import { motion } from "motion/react"; import { Check, Copy, RotateCcw } from "lucide-react"; import { cn } from "@/lib/utils"; export type TerminalLine = | { type: "command"; text: string } | { type: "output"; text: string; tone?: "default" | "success" | "error" | "muted" } | { type: "progress"; text: string; ms?: number }; export interface TerminalProps { lines: TerminalLine[]; title?: string; /** Type commands and reveal output over time. */ animate?: boolean; /** ms per character when typing commands. */ typingSpeed?: number; className?: string; } const tones = { default: "text-foreground/90", success: "text-emerald-500", error: "text-destructive", muted: "text-muted-foreground" }; /** A terminal window that can type its commands and stream output. */ export function Terminal({ lines, title = "zsh", animate = true, typingSpeed = 28, className }: TerminalProps) { const [shown, setShown] = React.useState(animate ? 0 : lines.length); const [typed, setTyped] = React.useState(0); const [run, setRun] = React.useState(0); const [copied, setCopied] = React.useState(false); const current = lines[shown]; React.useEffect(() => { if (!animate || shown >= lines.length) return; if (current.type === "command" && typed < current.text.length) { const t = setTimeout(() => setTyped((n) => n + 1), typingSpeed); return () => clearTimeout(t); } const delay = current.type === "command" ? 300 : current.type === "progress" ? current.ms ?? 700 : 120; const t = setTimeout(() => { setShown((n) => n + 1); setTyped(0); }, delay); return () => clearTimeout(t); }, [animate, shown, typed, current, lines.length, typingSpeed, run]); const commands = lines.filter((l) => l.type === "command").map((l) => l.text).join("\n"); return ( <div className={cn("overflow-hidden rounded-lg border border-border bg-[#0b0b0c] font-mono text-[12.5px] text-neutral-100 shadow-elev-2", className)} data-theme="dark"> <div className="flex items-center gap-3 border-b border-white/10 px-3.5 py-2"> <span className="flex gap-1.5" aria-hidden> <span className="size-2.5 rounded-full bg-[#ff5f57]" /> <span className="size-2.5 rounded-full bg-[#febc2e]" /> <span className="size-2.5 rounded-full bg-[#28c840]" /> </span> <span className="flex-1 text-center text-[11px] text-neutral-400">{title}</span> {animate && ( <button type="button" aria-label="Replay" onClick={() => { setShown(0); setTyped(0); setRun((r) => r + 1); }} className="grid size-6 place-items-center rounded text-neutral-400 hover:bg-white/10 hover:text-white"><RotateCcw className="size-3" /></button> )} <button type="button" aria-label="Copy commands" onClick={async () => { await navigator.clipboard.writeText(commands); setCopied(true); setTimeout(() => setCopied(false), 1200); }} className="grid size-6 place-items-center rounded text-neutral-400 hover:bg-white/10 hover:text-white"> {copied ? <Check className="size-3" /> : <Copy className="size-3" />} </button> </div> <div className="min-h-32 space-y-1 p-4" role="log" aria-live="polite"> {lines.slice(0, shown + 1).map((l, i) => { const partial = i === shown && animate; if (i === shown && !animate) return null; if (l.type === "command") return ( <div key={i} className="flex gap-2"> <span className="text-[#c6f432]">❯</span> <span>{partial ? l.text.slice(0, typed) : l.text}{partial && <span className="ml-px inline-block h-3.5 w-1.5 translate-y-0.5 animate-pulse bg-neutral-300" />}</span> </div> ); if (partial) return l.type === "progress" ? <motion.div key={i} className="text-neutral-400" animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 1 }}>⠋ {l.text}</motion.div> : null; return l.type === "progress" ? ( <div key={i} className="text-emerald-500">✓ <span className="text-neutral-300">{l.text}</span></div> ) : ( <motion.div key={i} initial={{ opacity: 0 }} animate={{ opacity: 1 }} className={cn("whitespace-pre-wrap", tones[l.tone ?? "default"])}>{l.text}</motion.div> ); })} </div> </div> ); }
Usage
Import it from @/components/ui/terminal after installing.
example.tsx
import { Terminal } from "@/components/ui/terminal";
export function Install() {
return (
<Terminal
lines={[
{ type: "command", text: "npx shadcn@latest add @lode/button" },
{ type: "progress", text: "Installing", ms: 900 },
{ type: "output", text: "✓ Done", tone: "success" },
]}
/>
);
}API reference
Terminal
A terminal window that types commands, runs progress lines and streams output.
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | TerminalLine[] | – | command, output (with tone) or progress (with ms) lines. |
| title | string | "zsh" | Window title. |
| animate | boolean | true | Type and reveal over time. |
| typingSpeed | number | 28 | ms per character. |
Keyboard interactions
| Keys | Action |
|---|---|
| Tab | Reach Replay and Copy |
Accessibility
- The output is role="log" with aria-live="polite", so lines are announced as they appear.
- Copy copies only the commands.
Motion
- Characters type in; a cursor blinks; progress lines fill.
Responsive behavior
- Long lines scroll horizontally.
Performance
- One timer drives the whole sequence.
Notes for AI
- Use type="progress" for waits rather than fake output lines.
This page as Markdown, for assistants and coding agents:
/components/terminal/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt