About
Command output with real ANSI colors: a test run streams in, one test fails with its diff and summary, the exit badge turns red, then the rerun passes green. Wrap, follow, clear and copy work from the header.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Terminal to your project and fits it to your theme.
Code
How Terminal is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client"
import * as React from "react"
import { Terminal } from "@/components/ui/terminal"
/** The first frame lands mid run, with the first test files already reported. */
const FIRST_COUNT = 6
/** [delay before the line in ms, line] */
type Step = [number, string]
const E = "\u001b["
const gray = (s: string) => `${E}90m${s}${E}39m`
const green = (s: string) => `${E}32m${s}${E}39m`
const amber = (s: string) => `${E}33m${s}${E}39m`
const HEAD: Step[] = [
[0, gray("> ledger@0.9.2 test /Users/mira/code/ledger")],
[0, gray("> vitest run")],
[0, ""],
[420, ` ${E}1m${E}46m RUN ${E}49m${E}22m ${gray("/Users/mira/code/ledger")}`],
[0, ""],
[260, ` ${green("✓")} src/lib/money.test.ts ${gray("(14 tests)")} ${gray("12ms")}`],
[160, ` ${green("✓")} src/lib/csv-import.test.ts ${gray("(38 tests)")} ${gray("41ms")}`],
[260, ` ${green("✓")} src/api/invoices.test.ts ${gray("(9 tests)")} ${amber("208ms")}`],
]
const red = (s: string) => `${E}31m${s}${E}39m`
const RULE = "⎯".repeat(7)
const bold = (s: string) => `${E}1m${s}${E}22m`
const dim = (s: string) => `${E}2m${s}${E}22m`
const FAIL_RUN: Step[] = [
...HEAD,
[380, ` ${red("❯")} src/sync/upload.test.ts ${gray(`(6 tests | ${red("1 failed")})`)} ${gray("96ms")}`],
[0, ` ${green("✓")} signs each request with the workspace key ${gray("4ms")}`],
[0, ` ${green("✓")} skips receipts it already sent ${gray("2ms")}`],
[0, ` ${red("×")} retries a failed upload with backoff ${gray("61ms")}`],
[0, ` ${red('→ expected "fetch" to be called 3 times, but got 2 times')}`],
[340, ` ${green("✓")} src/ui/report.test.tsx ${gray("(21 tests)")} ${amber("318ms")}`],
[220, ""],
[0, red(`${RULE} ${bold("Failed Tests 1")} ${RULE}`)],
[0, ""],
[
0,
`${E}41m${E}1m FAIL ${E}22m${E}49m src/sync/upload.test.ts ${gray(">")} upload ${gray(">")} retries a failed upload with backoff`,
],
[0, red(`${bold("AssertionError")}: expected "fetch" to be called 3 times, but got 2 times`)],
[0, ` ${E}36m❯ src/sync/upload.test.ts:${dim("88:22")}${E}39m`],
[0, ` ${gray(" 86|")} await upload(receipt, { maxRetries: 2 })`],
[0, ` ${gray(" 87|")}`],
[0, ` ${red(bold(" 88|"))} expect(fetchMock).toHaveBeenCalledTimes(3)`],
[0, ` ${gray(" |")} ${red("^")}`],
[0, ` ${gray(" 89|")} })`],
[0, ""],
[200, ` ${dim("Test Files ")} ${bold(red("1 failed"))}${dim(" | ")}${bold(green("4 passed"))} ${gray("(5)")}`],
[0, ` ${dim(" Tests ")} ${bold(red("1 failed"))}${dim(" | ")}${bold(green("87 passed"))} ${gray("(88)")}`],
[0, ` ${dim(" Duration ")} 2.31s ${gray("(transform 412ms, collect 1.20s, tests 685ms)")}`],
[0, ""],
[180, `${E}41m${E}1m ELIFECYCLE ${E}22m${E}49m ${red("Test failed. See above for more details.")}`],
]
const PASS_RUN: Step[] = [
...HEAD,
[380, ` ${green("✓")} src/sync/upload.test.ts ${gray("(6 tests)")} ${gray("88ms")}`],
[0, ` ${green("✓")} signs each request with the workspace key ${gray("4ms")}`],
[0, ` ${green("✓")} skips receipts it already sent ${gray("2ms")}`],
[0, ` ${green("✓")} retries a failed upload with backoff ${gray("58ms")}`],
[340, ` ${green("✓")} src/ui/report.test.tsx ${gray("(21 tests)")} ${amber("304ms")}`],
[220, ""],
[0, ` ${dim("Test Files ")} ${bold(green("5 passed"))} ${gray("(5)")}`],
[0, ` ${dim(" Tests ")} ${bold(green("88 passed"))} ${gray("(88)")}`],
[0, ` ${dim(" Duration ")} 2.04s ${gray("(transform 398ms, collect 1.14s, tests 602ms)")}`],
]
const RUNS = [
{ steps: FAIL_RUN, exitCode: 1, durationMs: 2310 },
{ steps: PASS_RUN, exitCode: 0, durationMs: 2040 },
]
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
/** Time on a finished run before the next one starts. */
const REST_MS = 1700
const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"
/**
* 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
* phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
* render white on dark pages. FitFrame fills the frame exactly instead.
*
* - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
* - "crop": the content stays at full size from the top and fades out at the bottom. For text
* that streams in, where a changing scale would make the page breathe.
*
* `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
* to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
* shrinks evenly on both sides.
*
* Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
* shorter than `hideBelow`, so a profile card shows the visual alone.
*/
function FitFrame({
children,
mode = "scale",
hideBelow = 360,
minWidth,
}: {
children: React.ReactNode
mode?: "scale" | "crop"
hideBelow?: number
minWidth?: string
}) {
const content = React.useRef<HTMLDivElement>(null)
const [cropped, setCropped] = React.useState(false)
React.useLayoutEffect(() => {
const el = content.current
if (!el) return
const measure = () => {
const vw = window.innerWidth
const vh = window.innerHeight
el.toggleAttribute("data-fit-small", vh < hideBelow)
// Layout sizes ignore the transform, so measuring never feeds back.
const w = Math.max(el.offsetWidth, el.scrollWidth)
const h = Math.max(el.offsetHeight, el.scrollHeight)
const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
el.style.transform = scale < 1 ? `scale(${scale})` : ""
setCropped(mode === "crop" && h > vh + 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
// Content that changes inside a fixed width box (a headline that cycles words) never resizes
// the box itself, so changes below it measure again on the next frame.
let frame = 0
const mutations = new MutationObserver(() => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(measure)
})
mutations.observe(el, { subtree: true, childList: true, characterData: true })
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
mutations.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener("resize", measure)
}
}, [mode, hideBelow])
const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
return (
<div
className="flex w-full justify-center overflow-hidden"
style={{
height: "100dvh",
alignItems: mode === "crop" ? "flex-start" : "center",
maskImage: cropped ? fade : undefined,
WebkitMaskImage: cropped ? fade : undefined,
}}
>
<style>{FIT_CSS}</style>
<div
ref={content}
className="w-full shrink-0"
style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
>
{children}
</div>
</div>
)
}
/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
return (
<p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
{children}{" "}
<a
href={`https://chatcn.dev/docs/components/${slug}`}
target="_blank"
rel="noopener"
className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
>
chatcn.dev
</a>
</p>
)
}
/**
* A `pnpm test` run streams in with real escape codes: one upload test fails with its diff and
* summary, the exit badge turns red, then the rerun passes green. Loops. Wrap, follow, clear and
* copy in the header all work.
*/
export default function TerminalPreview() {
const [run, setRun] = React.useState(0)
const [count, setCount] = React.useState(FIRST_COUNT)
const current = RUNS[run] ?? RUNS[0]!
const steps = current.steps
const done = count >= steps.length
React.useEffect(() => {
if (reducedMotion()) {
setCount(FAIL_RUN.length)
return
}
}, [])
React.useEffect(() => {
if (reducedMotion()) return
const delay = done ? REST_MS : (steps[count]?.[0] ?? 0) || 40
const id = window.setTimeout(() => {
if (done) {
setRun((r) => (r + 1) % RUNS.length)
setCount(0)
} else setCount((c) => c + 1)
}, delay)
return () => window.clearTimeout(id)
}, [count, done, steps])
const text = React.useMemo(
() =>
steps
.slice(0, count)
.map(([, line]) => `${line}\n`)
.join(""),
[steps, count],
)
return (
<FitFrame minWidth="42rem">
<div className="flex w-full flex-col items-center justify-center gap-4 p-6">
<div className="w-full max-w-2xl">
<Terminal
command="pnpm test"
cwd="~/code/ledger"
text={text}
running={!done}
exitCode={done ? current.exitCode : undefined}
durationMs={done ? current.durationMs : undefined}
height={292}
/>
</div>
<MoreOn slug="terminal">ANSI colors, live follow, exit codes and timing for any process. On</MoreOn>
</div>
</FitFrame>
)
}








