DocsBuilding
Components
Every component is imported from "terminaltui" and returns a content block. Content blocks are always used inside an array:
content: [markdown("Hello"), card({ title: "World" })]
Display Components
markdown(text)
Renders text with markdown formatting (bold, italic, inline code, code blocks). Not focusable.
markdown("This is **bold** and *italic* with `inline code`.")
card(config)
A bordered card. Focusable — each card is an individual navigation target.
| Prop | Type | Description |
|---|---|---|
title | string | Card heading (required) |
subtitle | string | Secondary text (price, date, stars) |
body | string | Body text |
tags | string[] | Tags rendered as badges |
url | string | URL opened on Enter |
border | BorderStyle | Override default border style |
action | CardAction | Action on select |
CardAction supports onPress, navigate (page ID), params (route params), confirm (confirmation prompt), label, and style ("primary", "secondary", "danger").
card({
title: "My Project",
subtitle: "★ 200",
body: "A brief description of the project.",
tags: ["TypeScript", "Open Source"],
url: "https://github.com/user/repo",
action: { navigate: "project-detail", params: { id: "my-project" } },
})
timeline(items)
Vertical timeline with connected dots. Each item is focusable but display-only (no action on Enter). For browsable items, prefer card() blocks instead.
| Item Prop | Type | Description |
|---|---|---|
title | string | Entry heading |
subtitle | string | Organization or company |
period | string | Time range |
description | string | Details |
timeline([
{ title: "Senior Engineer", subtitle: "Acme Corp", period: "2023 -- present", description: "Platform team lead" },
{ title: "BS Computer Science", subtitle: "University", period: "2017 -- 2021" },
])
table(headers, rows)
A bordered data table. Not focusable.
table(
["Plan", "Price", "Features"],
[
["Free", "$0/mo", "Basic features"],
["Pro", "$10/mo", "Everything + priority support"],
]
)
list(items, style?)
A styled list. Not focusable. Styles: "bullet" (default), "number", "dash", "check", "arrow".
list(["First item", "Second item", "Third item"], "check")
quote(text, attribution?)
Block quote with optional attribution. Not focusable.
quote("The best way to predict the future is to invent it.", "-- Alan Kay")
hero(config)
Large hero section. Focusable if cta is set (opens URL on Enter).
| Prop | Type | Description |
|---|---|---|
title | string | Large heading |
subtitle | string | Description |
cta | { label, url } | Call-to-action link |
art | string | Custom ASCII art |
hero({
title: "Welcome",
subtitle: "Build beautiful terminal apps.",
cta: { label: "Get Started", url: "https://example.com" },
})
gallery(items)
Grid of cards. Items use the same shape as card().
gallery([
{ title: "Photo 1", body: "Description", tags: ["nature"] },
{ title: "Photo 2", body: "Description", tags: ["urban"] },
])
tabs(items)
Tabbed content. Focusable as one block — Enter cycles through tabs. Best for mutually exclusive views of the same data, not for organizing sequential page sections (use divider() for that).
tabs([
{ label: "Frontend", content: [list(["React", "Vue", "Svelte"], "check")] },
{ label: "Backend", content: [list(["Node.js", "Python", "Go"], "check")] },
])
accordion(items)
Collapsible sections. Each item is separately focusable. Enter toggles open/close.
accordion([
{ label: "What is terminaltui?", content: [markdown("A framework for building terminal websites.")] },
{ label: "How do I deploy?", content: [markdown("Run `terminaltui build` then `npm publish`.")] },
])
link(label, url, options?)
A clickable link. Focusable — opens URL in the user’s browser on Enter.
Options: icon (single character displayed before the label).
link("GitHub", "https://github.com/user")
link("Email", "mailto:hello@example.com", { icon: "✉" })
progressBar(label, value, max?)
Progress bar with percentage. Max defaults to 100. Not focusable.
progressBar("Project Alpha", 7, 10)
progressBar("Completion", 65)
skillBar(label, value) is a shorthand for progressBar(label, value, 100):
skillBar("TypeScript", 90)
skillBar("Rust", 75)
badge(text, color?)
Inline badge or tag. Not focusable. Color is a hex string.
badge("v2.0")
badge("NEW", "#50fa7b")
image(path, options?)
Renders a real PNG or JPEG as colored terminal cells. Not focusable. No graphics protocol and no native dependency — the output is styled text, so it works on Apple Terminal, over SSH, in tmux, and in the test emulator.
| Option | Type | Default | Description |
|---|---|---|---|
width | number | fills available width | Width in terminal cells (max 99) |
height | number | derived from aspect | Height in rows — a ceiling under fit: "contain" |
maxHeight | number | panel height, else 200 | Cap on derived rows |
fit | "contain" | "cover" | "fill" | "contain" | "contain" shrinks the block rather than letterboxing |
align | "left" | "center" | "right" | "center" | Placement in the block’s allocation |
mode | "auto" | "quadrant" | "half" | "solid" | "shading" | "ascii" | "braille" | "alt" | "auto" | Force a rendering tier. "blocks" still works as an alias for "half" |
dither | "auto" | "ordered" | "floyd-steinberg" | "none" | "auto" | Ordered Bayer in 256/16 color, none in truecolor |
alt | string | file basename | Shown in a bordered box on any failure |
background | string | theme background | Hex composited under transparent pixels |
invert | boolean | false | Negate the image’s ink (transparent areas keep the page background) |
charset | string | " .:-=+*#%@" | Ramp for the ascii and shading tiers |
border | boolean | BorderStyle | false | Themed border, adds 2 columns and 2 rows |
resizable | boolean | false | Viewer resizes the frame with +/-. Makes the block focusable, adds one hint row |
fitPage | boolean | false | Size to the rows the page has left instead of to a hand-picked width. Inert on a resizable image and inside a panel |
image("./logo.png")
image("./photo.jpg", { width: 60, maxHeight: 20, alt: "Cover art" })
image("./plot.png", { mode: "braille", width: 60 }) // line art
image("./hero.png", { width: 40, fit: "cover", border: true })
image("./poster.jpg", { fitPage: true, border: true }) // fits any window, no constant
mode: "auto" picks the best technique the viewer’s terminal supports: 2x2 quadrant cells on a 256-color or truecolor terminal, half blocks under tmux, a shading ramp at 16 colors, plain ASCII when color is off. Paths are relative to the project root, not the working directory. GIF, WebP, BMP and http(s) sources have no synchronous decoder and render the alt box at the same size the image would have taken, so layout never shifts.
Full reference: docs/images.md.
section(title, content)
Groups content under a titled section header with a divider line. Not focusable itself — children inherit their own focusability.
section("Appetizers", [
card({ title: "Bruschetta", subtitle: "$12", body: "Toasted bread with tomatoes" }),
])
divider(style?, label?)
Horizontal divider line. Not focusable. Styles: "solid", "dashed", "dotted", "double", "label". If the first argument is not a known style, it becomes a label automatically.
divider() // solid line
divider("dashed") // dashed line
divider("My Section") // labeled divider (auto-detected)
spacer(lines?)
Vertical whitespace. Defaults to 1 line. Not focusable.
spacer() // 1 blank line
spacer(3) // 3 blank lines
Input Components
All inputs are focusable. In navigation mode, press Enter to start editing, Escape to return to navigation.
textInput(config)
| Prop | Type | Description |
|---|---|---|
id | string | Unique input ID (required) |
label | string | Label text (required) |
placeholder | string | Placeholder text |
defaultValue | string | Initial value |
maxLength | number | Max character count |
validate | (value) => string | null | Return error message or null |
mask | boolean | Mask input (for passwords) |
transform | (value) => string | Transform input on change |
textInput({ id: "name", label: "Your Name", placeholder: "Enter name...", maxLength: 50 })
textInput({ id: "password", label: "Password", mask: true })
textArea(config)
Same as textInput plus rows (visible rows). Supports multi-line editing.
textArea({ id: "bio", label: "Bio", placeholder: "Tell us about yourself...", rows: 4, maxLength: 500 })
select(config)
Dropdown select. Enter opens the dropdown, arrow keys pick an option.
| Prop | Type | Description |
|---|---|---|
id | string | Unique input ID (required) |
label | string | Label text (required) |
options | { label, value }[] | Options array (required) |
defaultValue | string | Initial selected value |
placeholder | string | Placeholder text |
onChange | (value) => void | Change handler |
select({
id: "color",
label: "Favorite Color",
options: [{ label: "Red", value: "red" }, { label: "Blue", value: "blue" }],
onChange: (val) => console.log("Selected:", val),
})
checkbox(config)
Toggle checkbox. Enter or Space toggles.
checkbox({ id: "agree", label: "I agree to the terms", onChange: (val) => console.log(val) })
toggle(config)
Toggle switch with on/off labels. Enter or Space toggles.
toggle({ id: "dark", label: "Dark Mode", onLabel: "ON", offLabel: "OFF", defaultValue: true })
radioGroup(config)
Radio button group. Enter starts selection, arrow keys move between options.
radioGroup({
id: "plan",
label: "Select Plan",
options: [{ label: "Free", value: "free" }, { label: "Pro", value: "pro" }],
defaultValue: "free",
})
numberInput(config)
Numeric input. Left/Right arrow keys change the value.
numberInput({ id: "qty", label: "Quantity", defaultValue: 1, min: 1, max: 99, step: 1 })
searchInput(config)
Search with filtering. Type to filter, arrows to pick, Enter to select. Set action: "navigate" to navigate to the selected value as a page ID.
searchInput({
id: "search",
placeholder: "Search pages...",
items: [
{ label: "About", value: "about", keywords: ["bio", "info"] },
{ label: "Projects", value: "projects", keywords: ["work", "code"] },
],
action: "navigate",
})
button(config)
A pressable button. Enter triggers the action.
button({ label: "Submit", style: "primary", onPress: async () => { /* ... */ } })
Styles: "primary", "secondary", "danger".
form(config)
Groups input fields and a submit button. On submit, collects all field values by their id.
The onSubmit handler must return an ActionResult: { success: "..." }, { error: "..." }, or { info: "..." }.
form({
id: "contact",
onSubmit: async (data) => {
await sendEmail(data.name, data.email, data.message);
return { success: "Message sent!" };
},
fields: [
textInput({ id: "name", label: "Name" }),
textInput({ id: "email", label: "Email" }),
textArea({ id: "message", label: "Message", rows: 4 }),
button({ label: "Send", style: "primary" }),
],
})
Layout Components
Layout components divide the terminal into panels. See layouts.md for full documentation.
columns(panels)
Side-by-side panels. Each panel gets a width ("50%", 30, or omit for equal split).
columns([
panel({ width: "60%", content: [table(["Name", "Status"], [["nginx", "running"]])] }),
panel({ width: "40%", content: [progressBar("CPU", 45)] }),
])
rows(panels)
Vertically stacked panels with height ("50%", 10, or omit for equal split).
rows([
panel({ height: "40%", content: [markdown("## Top")] }),
panel({ height: "60%", content: [markdown("## Bottom")] }),
])
grid(config)
N×M grid. cols: columns. gap: character gap (default 1). items: array of panel() configs.
grid({ cols: 2, items: [
panel({ title: "CPU", content: [progressBar("Usage", 45)] }),
panel({ title: "Mem", content: [progressBar("RAM", 72)] }),
]})
panel(config)
Bordered content area with optional title, border, padding, width, height.
panel({ title: "Stats", border: "rounded", padding: 1, content: [progressBar("CPU", 45)] })
row(cols, config?) + col(content, config?)
12-column grid system. span is 1-12 (default: auto). Responsive breakpoints: xs, sm, md, lg.
row([
col([sidebar], { span: 3 }),
col([content], { span: 9 }),
], { gap: 1 })
container(content, config?)
Centers content with optional maxWidth and padding.
container([row([...])], { maxWidth: 100, padding: 2 })
Navigation: Arrow keys navigate spatially between focusable items in any layout. No configuration needed.
Dynamic Components
dynamic(renderFn) / dynamic(deps, renderFn)
Reactive content block that re-renders when state changes. Optionally specify dependency keys to limit re-renders.
dynamic(() => markdown(`Count: ${state.get("count")}`))
dynamic(["count"], () => markdown(`Count: ${state.get("count")}`))
asyncContent(config)
Lazily-loaded async content with loading and fallback states.
asyncContent({
load: async () => {
const data = await fetchData();
return [card({ title: data.name, body: data.description })];
},
loading: "Loading data...",
fallback: [markdown("Failed to load.")],
})
Custom Components
You can register custom component renderers using the componentRegistry:
import { componentRegistry } from "terminaltui";
import type { RenderContext } from "terminaltui";
componentRegistry.register("myWidget", (block, ctx: RenderContext) => {
// Return an array of ANSI-styled lines
return [` ${block.label}: ${block.value}`];
}, true); // true = focusable
Use your custom component in content arrays by creating a block with a matching type:
content: [
{ type: "myWidget", label: "Score", value: "42" } as any,
]
For architecture details and how to contribute new built-in components, see ARCHITECTURE.md.