Terminal
A command line for your app: a title bar, a monospace scrollback and a prompt with ghost completion and a block cursor. Presentational parts on the theme's tokens; your app runs the commands, so it fits a dev console, a search console or a REPL alike.
Installation#
Usage#
The parts paint; the loop is yours. A working shell is a list of lines, the text being typed, and a key handler that turns Enter into output.
import * as React from "react"
import {
Terminal,
TerminalField,
TerminalInputLine,
TerminalLine,
TerminalPrompt,
TerminalScreen,
type TerminalTone,
} from "@/components/ui/terminal"
type Line = { id: number; text: string; tone: TerminalTone }
export function Example() {
const [lines, setLines] = React.useState<Line[]>([])
const [input, setInput] = React.useState("")
const run = (command: string) => {
if (command === "clear") return setLines([])
setLines((l) => [
...l,
{ id: l.length, text: command, tone: "in" },
{ id: l.length + 1, text: `you said: ${command}`, tone: "out" },
])
}
return (
<Terminal className="h-80">
<TerminalScreen>
{lines.map((line) => (
<TerminalLine key={line.id} tone={line.tone}>
{line.tone === "in" && <TerminalPrompt prefix="app" />}
{line.text}
</TerminalLine>
))}
<TerminalInputLine>
<TerminalPrompt prefix="app" />
<TerminalField
aria-label="Command"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return
run(input.trim())
setInput("")
}}
/>
</TerminalInputLine>
</TerminalScreen>
</Terminal>
)
}Anatomy#
import {
Terminal,
TerminalBar,
TerminalControls,
TerminalField,
TerminalIndicator,
TerminalInputLine,
TerminalLine,
TerminalPrompt,
TerminalScreen,
TerminalTitle,
TerminalTitleTag,
TerminalTray,
TerminalWatermark,
} from "@/components/ui/terminal"
<Terminal>
{/* A faint stamp behind the scrollback */}
<TerminalWatermark />
{/* The title bar, and the drag handle */}
<TerminalBar>
<TerminalIndicator />
<TerminalTitle>
<TerminalTitleTag />
</TerminalTitle>
<TerminalControls />
</TerminalBar>
{/* The scrollback, a ScrollArea */}
<TerminalScreen>
<TerminalLine />
<TerminalInputLine>
<TerminalPrompt />
<TerminalField />
</TerminalInputLine>
</TerminalScreen>
{/* Live results, suggestions, a status line */}
<TerminalTray />
</Terminal>Line tones#
tone sets a line's ink. in is a command that was run, in the foreground ink; out (the default) and raw are output in the reading ink; dim is a comment in the meta grey; err and ok take the status hues' text step, which still reads on the light themes.
Clickable lines#
Give a line onClick and it becomes a button: focusable, run by a click, Enter or Space, with a highlight that keeps the text on its column. For search results, a file listing, or a #N the user would otherwise have to type.
Prompt, completion and cursor#
TerminalPrompt takes a prefix and a mark ("> " by default). TerminalField paints what is typed and a block cursor at the caret: it follows the arrow keys and sits over the character it is on. ghost is the grey completion after it; accepting it is your key handler's job (Tab or → here, at the end of the line). The cursor is a solid block while the field has focus and a hollow cell when it doesn't; drive cursorVisible from a timer to make it blink.
A blink that restarts lit on every keystroke, and holds still under reduced motion:
function BlinkingField(props: Omit<TerminalFieldProps, "cursorVisible">) {
const [beat, setBeat] = React.useState({ value: props.value, lit: true })
if (beat.value !== props.value) setBeat({ value: props.value, lit: true })
React.useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
const timer = setInterval(() => setBeat((b) => ({ ...b, lit: !b.lit })), 530)
return () => clearInterval(timer)
}, [props.value])
return <TerminalField cursorVisible={beat.lit} {...props} />
}Tray#
TerminalTray is the band under the scrollback, on a quieter tint: live results while the user types, suggestions, a status line. It holds Koala components (a Badge, a Kbd, a Command list) or clickable TerminalLines, as here.
Window chrome#
TerminalBar carries the TerminalIndicator, a TerminalTitle (with an optional TerminalTitleTag) and TerminalControls, where buttons default to the 32px tier that fits the bar. What the controls do is yours: here they minimize and widen the window, and a double-click on the bar widens it too. movable gives the bar a grab cursor and dragging holds the grabbing one while your pointer handlers move the window. TerminalWatermark lays a faint stamp behind the scrollback.
API reference#
Terminal
The surface, a div: the overlay panel (popover fill, the xl shadow) every part reads its styles from. Every part is optional but the root. Forwards every div prop; every part merges className last. terminalVariants is the recipe.
| Prop | Type | Default | Description |
|---|---|---|---|
| movable | boolean | false | The bar shows a grab cursor. Moving the window is your pointer handler on TerminalBar. |
| dragging | boolean | false | The grabbing cursor, and no text selection while it lasts. |
| Data attribute | Description |
|---|---|
| data-slot | "terminal". |
| CSS variable | Description |
|---|---|
| --surface | Set to var(--popover), so controls nested inside paint the panel's ground. |
TerminalBar
The title bar, a div, and the drag handle: hang onPointerDown on it. Drop it for a console embedded in a page.
| Data attribute | Description |
|---|---|
| data-slot | "terminal-bar". |
TerminalIndicator
The live dot before the title, a decorative span (aria-hidden).
| Data attribute | Description |
|---|---|
| data-slot | "terminal-indicator". |
TerminalTitle
The title, a span that ends a long title in an ellipsis.
| Data attribute | Description |
|---|---|
| data-slot | "terminal-title". |
TerminalTitleTag
The title's secondary label, a span after a middle dot. Goes inside TerminalTitle.
| Data attribute | Description |
|---|---|
| data-slot | "terminal-title-tag". |
TerminalControls
The window buttons at the end of the bar, a div. Controls inside default to the sm tier (32px); an explicit size still wins.
| Data attribute | Description |
|---|---|
| data-slot | "terminal-controls". |
| data-control-size | "sm". |
TerminalScreen
The scrollback, on ScrollArea: takes its props but the axis (orientation and draggable are left out). Its content is a role="log", so assistive tech announces new lines.
| Prop | Type | Default | Description |
|---|---|---|---|
| fade | boolean | false | Fades the edge that still has content beyond it. |
| viewportRef | React.Ref<HTMLDivElement> | - | The scrolling element itself: use it to keep the scrollback at the bottom. |
| type | "auto" | "always" | "scroll" | "hover" | "hover" | When the scrollbars show, as on ScrollArea. |
| Data attribute | Description |
|---|---|
| data-slot | "terminal-screen", on the log inside the scroller. |
TerminalWatermark
A faint stamp behind the scrollback (a logo, a seal), a decorative img (aria-hidden, not draggable). Set its opacity with style. Forwards every img prop.
| Prop | Type | Default | Description |
|---|---|---|---|
| alt | string | "" | Empty by default: the image is decorative. |
| Data attribute | Description |
|---|---|
| data-slot | "terminal-watermark". |
TerminalLine
One line of the scrollback, a div that wraps long text. It needs no Terminal around it. terminalLineVariants({ tone, interactive, className }) returns a line's classes for your own markup.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "in" | "out" | "err" | "ok" | "dim" | "raw" | "out" | The line's ink. The type is exported as TerminalTone. |
| onClick | (e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => void | - | Makes the line a role="button" in the tab order, run by click, Enter or Space. |
| Data attribute | Description |
|---|---|
| data-slot | "terminal-line". |
| data-tone | The line's tone. |
TerminalInputLine
The prompt row, a div: the prompt and the field on one baseline. Keep it last in the screen so the prompt scrolls with the output, the way a shell does.
| Data attribute | Description |
|---|---|
| data-slot | "terminal-input-line". |
TerminalPrompt
The prompt, a span: the prefix in brand ink, then the mark.
| Prop | Type | Default | Description |
|---|---|---|---|
| prefix* | React.ReactNode | - | What comes before the mark: the system, the host, the unit. |
| mark | React.ReactNode | "> " | The prompt mark. Keep its trailing space. |
| Data attribute | Description |
|---|---|
| data-slot | "terminal-prompt", with terminal-prompt-prefix and terminal-prompt-mark inside. |
TerminalField
The prompt's field: painted text with a block cursor at the caret, under a transparent <input> that takes the keyboard. Every input prop goes to that input, className and ref included; spellcheck, autocomplete, autocapitalize and autocorrect are off unless you turn them on.
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | - | The controlled text. |
| ghost | string | - | The grey completion painted after the caret when it is at the end. Your key handler accepts it, usually on Tab. |
| cursorVisible | boolean | true | The blink beat while focused: drive it from a timer to blink, leave it true for a steady cursor. Out of focus the cursor is a still, hollow cell. |
| placeholder | string | - | Painted in the ghost's grey while the field is empty. |
| Data attribute | Description |
|---|---|
| data-slot | "terminal-field" on the wrapper, "terminal-input" on the input, "terminal-cursor" on the cursor. |
| data-visible | On the cursor: the cursorVisible beat. |
TerminalTray
The band under the scrollback, a div: live results, suggestions, a status line. Drop it when nothing runs live.
| Data attribute | Description |
|---|---|
| data-slot | "terminal-tray". |
