A terminal window with command, output, success, error and comment lines, optional per-command copy buttons and a sequenced typing animation that respects reduced motion.
import { Terminal, TerminalLine } from "@/components/ballmac/terminal"
export default function TerminalDemo() {
return (
<Terminal title="~/my-app" className="w-full max-w-xl">
<TerminalLine variant="command" copyable>
pnpm dlx shadcn@latest add @ballmac/button
</TerminalLine>
<TerminalLine>✔ Checking registry.</TerminalLine>
<TerminalLine>✔ Installing dependencies.</TerminalLine>
<TerminalLine variant="success">Created 1 file: components/ballmac/button.tsx</TerminalLine>
<TerminalLine variant="comment"># Import it from @/components/ballmac/button</TerminalLine>
</Terminal>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/terminalInstall the dependencies.
$ pnpm add class-variance-authority lucide-react motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/terminal.tsx// Ballmac UI: Terminal. https://ui.ballmac.com/components/terminal "use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Check, Copy } from "lucide-react" import { motion, useInView, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type TerminalProps = React.ComponentProps<"figure"> & { /** Text in the title bar, e.g. "zsh" or "~/my-app". */ title?: string /** "dark" scopes the theme's dark tokens to the window in light mode too; "inherit" follows the page. */ theme?: "dark" | "inherit" /** Classes for the scrolling body. */ bodyClassName?: string } function Terminal({ title, theme = "dark", bodyClassName, className, children, ...props }: TerminalProps) { const msg = useMessages() return ( <figure data-slot="terminal" dir="ltr" className={cn( "w-full min-w-0 overflow-hidden rounded-xl border bg-card text-card-foreground shadow-[0_1px_2px_0_rgb(0_0_0/0.08)]", theme === "dark" && "dark [color-scheme:dark]", className )} {...props} > <figcaption data-slot="terminal-header" className="relative flex h-9 items-center border-b bg-muted/40 px-3.5" > <span aria-hidden="true" className="flex gap-1.5"> <span className="size-2.5 rounded-full bg-muted-foreground/35" /> <span className="size-2.5 rounded-full bg-muted-foreground/35" /> <span className="size-2.5 rounded-full bg-muted-foreground/35" /> </span> <span className="absolute inset-x-16 truncate text-center font-mono text-xs text-muted-foreground"> {title ?? <span className="sr-only">{msg("terminal.terminal", "Terminal")}</span>} </span> </figcaption> <div data-slot="terminal-body" tabIndex={0} className={cn("overflow-x-auto px-4 py-3.5 font-mono text-[13px] leading-6 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50", bodyClassName)} > {children} </div> </figure> ) } /* ------------------------------------------------------------------------------------------------- * Sequencing: TerminalAnimated reveals lines one after another. * -----------------------------------------------------------------------------------------------*/ type SequenceContextValue = { active: number; done: (index: number) => void; lineDelay: number; speed: number } const SequenceContext = React.createContext<SequenceContextValue | null>(null) const LineIndexContext = React.createContext<number>(-1) type TerminalAnimatedProps = React.ComponentProps<"div"> & { /** Typing speed for command lines, in characters per second. */ speed?: number /** Pause before each non-command line appears, in milliseconds. */ lineDelay?: number /** Wait before the first line starts, in milliseconds. */ startDelay?: number /** Start only once the terminal scrolls into view. */ startOnView?: boolean /** Called when every line has been revealed. */ onComplete?: () => void } /** Wrap TerminalLine children to reveal them in order: commands type out, output lines follow. Reduced motion shows everything at once. */ function TerminalAnimated({ speed = 36, lineDelay = 140, startDelay = 300, startOnView = true, onComplete, className, children, ...props }: TerminalAnimatedProps) { const ref = React.useRef<HTMLDivElement>(null) const inView = useInView(ref, { once: true, margin: "0px 0px -10% 0px" }) const reduceMotion = useReducedMotion() const items = React.Children.toArray(children) const total = items.length const [active, setActive] = React.useState(-1) const onCompleteRef = React.useRef(onComplete) React.useEffect(() => { onCompleteRef.current = onComplete }) React.useEffect(() => { if (reduceMotion) { setActive(total) return } if (active !== -1 || (startOnView && !inView)) return const t = setTimeout(() => setActive(0), startDelay) return () => clearTimeout(t) }, [reduceMotion, total, active, startOnView, inView, startDelay]) React.useEffect(() => { if (active >= total && total > 0) onCompleteRef.current?.() }, [active, total]) const done = React.useCallback((index: number) => { setActive((current) => (current === index ? index + 1 : current)) }, []) const ctx = React.useMemo(() => ({ active, done, lineDelay, speed }), [active, done, lineDelay, speed]) return ( <SequenceContext.Provider value={ctx}> <div ref={ref} data-slot="terminal-animated" aria-busy={active < total} className={cn("min-h-6", className)} {...props} > {items.map((child, index) => ( <LineIndexContext.Provider key={React.isValidElement(child) && child.key != null ? child.key : index} value={index}> {child} </LineIndexContext.Provider> ))} </div> </SequenceContext.Provider> ) } /* ------------------------------------------------------------------------------------------------- * Lines * -----------------------------------------------------------------------------------------------*/ const terminalLineVariants = cva("group/line relative flex min-w-0 items-start gap-2 whitespace-pre", { variants: { variant: { command: "text-foreground", output: "text-foreground/75", success: "text-chart-2", error: "text-destructive", comment: "text-muted-foreground italic", }, }, defaultVariants: { variant: "output" }, }) function Caret() { const reduceMotion = useReducedMotion() const className = "inline-block h-[1.15em] w-[0.6ch] translate-y-[0.2em] bg-foreground/80" if (reduceMotion) return <span aria-hidden="true" className={className} /> return ( <motion.span aria-hidden="true" className={className} animate={{ opacity: [1, 1, 0, 0] }} transition={{ duration: 1, times: [0, 0.5, 0.5, 1], repeat: Infinity, ease: "linear" }} /> ) } function useTyping(text: string, enabled: boolean, speed: number, onDone: () => void) { const [count, setCount] = React.useState(0) const onDoneRef = React.useRef(onDone) React.useEffect(() => { onDoneRef.current = onDone }) React.useEffect(() => { if (!enabled) return let i = 0 setCount(0) const interval = setInterval(() => { i += 1 setCount(i) if (i >= text.length) { clearInterval(interval) onDoneRef.current() } }, 1000 / speed) return () => clearInterval(interval) }, [enabled, text, speed]) return count } function LineCopyButton({ value }: { value: string }) { const msg = useMessages() const [copied, setCopied] = React.useState(false) const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined) React.useEffect(() => () => clearTimeout(timer.current), []) return ( <> <button type="button" aria-label={copied ? msg("terminal.copied", "Copied") : msg("terminal.copyCommand", "Copy command")} title={msg("terminal.copyCommand", "Copy command")} onClick={async () => { try { await navigator.clipboard.writeText(value) } catch { return } setCopied(true) clearTimeout(timer.current) timer.current = setTimeout(() => setCopied(false), 1600) }} className="ms-auto flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-opacity duration-150 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/line:opacity-100" > {copied ? <Check aria-hidden="true" className="size-3.5" /> : <Copy aria-hidden="true" className="size-3.5" />} </button> <span className="sr-only" aria-live="polite"> {copied ? "Copied to clipboard" : ""} </span> </> ) } type TerminalLineProps = React.ComponentProps<"div"> & VariantProps<typeof terminalLineVariants> & { /** Prompt shown before command lines. */ prompt?: string /** Show a copy button for command lines (copies the text without the prompt). */ copyable?: boolean /** Type the command out character by character (string children only). Implied inside TerminalAnimated. */ typing?: boolean /** Characters per second when typing outside TerminalAnimated. */ speed?: number } function TerminalLine({ variant = "output", prompt = "$", copyable = false, typing, speed, className, children, ...props }: TerminalLineProps) { const sequence = React.useContext(SequenceContext) const index = React.useContext(LineIndexContext) const reduceMotion = useReducedMotion() const inSequence = sequence !== null && index >= 0 const isCommand = variant === "command" const text = typeof children === "string" ? children : null const isActive = inSequence && sequence.active === index const typeThis = isCommand && text !== null && !reduceMotion && (inSequence ? typing !== false : !!typing) // Standalone typing starts on mount; sequenced typing when this line becomes active. const [mounted, setMounted] = React.useState(false) React.useEffect(() => setMounted(true), []) const typingEnabled = typeThis && (inSequence ? isActive : mounted) const finish = React.useCallback(() => { if (inSequence) sequence.done(index) }, [inSequence, sequence, index]) const typed = useTyping(text ?? "", typingEnabled, speed ?? sequence?.speed ?? 36, finish) // Non-typing lines inside a sequence advance after a short pause. React.useEffect(() => { if (!isActive || typeThis) return const t = setTimeout(finish, sequence?.lineDelay ?? 0) return () => clearTimeout(t) }, [isActive, typeThis, finish, sequence?.lineDelay]) if (inSequence) { const visible = index < sequence.active || (isActive && typeThis) if (!visible) return null } const stillTyping = typeThis && (inSequence ? index >= sequence!.active : typed < (text?.length ?? 0)) const content = typeThis && stillTyping ? (text ?? "").slice(0, typed) : children return ( <div data-slot="terminal-line" data-variant={variant} className={cn(terminalLineVariants({ variant }), className)} {...props} > {isCommand ? ( <span aria-hidden="true" className="shrink-0 text-muted-foreground select-none"> {prompt} </span> ) : null} {variant === "success" || variant === "error" ? ( <> <span aria-hidden="true" className="shrink-0 select-none"> {variant === "success" ? "✓" : "✗"} </span> <span className="sr-only">{variant === "success" ? "Success: " : "Error: "}</span> </> ) : null} <span className="min-w-0"> {content} {typeThis && stillTyping ? <Caret /> : null} </span> {isCommand && copyable && text !== null && !stillTyping ? <LineCopyButton value={text} /> : null} </div> ) } export { Terminal, TerminalLine, TerminalAnimated, terminalLineVariants, type TerminalProps, type TerminalLineProps, type TerminalAnimatedProps }Update the import paths to match your project setup.
Usage
import { Terminal, TerminalLine, TerminalAnimated } from "@/components/ballmac/terminal"The full example is in the Code tab above.
Examples
Typing animation
import { Terminal, TerminalAnimated, TerminalLine } from "@/components/ballmac/terminal"
export default function TerminalTyping() {
return (
<Terminal title="zsh" className="w-full max-w-xl">
<TerminalAnimated speed={40} lineDelay={350}>
<TerminalLine variant="command">npx shadcn@latest registry add @ballmac=https://ui.ballmac.com/r/{"{name}"}.json</TerminalLine>
<TerminalLine variant="success">Added @ballmac to components.json</TerminalLine>
<TerminalLine variant="command">npx shadcn@latest add @ballmac/prompt-input</TerminalLine>
<TerminalLine variant="success">Created 2 files</TerminalLine>
</TerminalAnimated>
</Terminal>
)
}API reference
<Terminal>
| Prop | Type | Default |
|---|---|---|
titleText in the title bar, e.g. "zsh" or "~/my-app". | string | — |
theme"dark" scopes the theme's dark tokens to the window in light mode too; "inherit" follows the page. | "dark" | "inherit" | "dark" |
bodyClassNameClasses for the scrolling body. | string | — |
<TerminalAnimated>
| Prop | Type | Default |
|---|---|---|
speedTyping speed for command lines, in characters per second. | number | 36 |
lineDelayPause before each non-command line appears, in milliseconds. | number | 140 |
startDelayWait before the first line starts, in milliseconds. | number | 300 |
startOnViewStart only once the terminal scrolls into view. | boolean | true |
onCompleteCalled when every line has been revealed. | () => void | — |
<TerminalLine>
| Prop | Type | Default |
|---|---|---|
promptPrompt shown before command lines. | string | "$" |
copyableShow a copy button for command lines (copies the text without the prompt). | boolean | false |
typingType the command out character by character (string children only). Implied inside TerminalAnimated. | boolean | — |
speedCharacters per second when typing outside TerminalAnimated. | number | — |
variant | "command" | "output" | "success" | "error" | "comment" | "output" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Focuses the copy button on command lines (hidden until hover on mouse devices, visible on focus) |
| — | The prompt symbol is hidden from screen readers; success and error lines carry a text prefix, not just color; aria-busy is set while animating |
Use with AI
Show CLI sessions: <Terminal title> with <TerminalLine variant='command' copyable> and output lines. Wrap lines in <TerminalAnimated> to type commands out and reveal output in order when scrolled into view. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Terminal (@ballmac/terminal) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Install and setup steps on landing pages and docs
- Showing what a CLI prints, including success and error lines
- An animated hero that types a command and its result
Not for
- Copyable install commands for several package managers (use install-tabs)
- Source code files (use code-block)
- A real interactive shell (this renders static or scripted lines only)
Registry JSON: https://ui.ballmac.com/r/terminal.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Install Tabs
Package-manager tabs for a CLI command (pnpm, npm, yarn, bun) with a copy button. Remembers the reader's choice and keeps every instance on the page in sync.
Code Block
A code panel with filename and language header, line numbers, highlighted lines, a wrap toggle, copy feedback and file tabs. No highlighter bundled; pass Shiki output as children.
API Endpoint
An endpoint reference card: method and path with highlighted parameters, auth, grouped parameter lists, and request and response examples in tabs with copy.
API Key Field
Displays a secret such as an API key, masked by default (sk-live-••••••••a1b2), with a reveal toggle, a copy button that copies the full key, and an optional regenerate action.