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.

PropTypeDescription
titlestringCard heading (required)
subtitlestringSecondary text (price, date, stars)
bodystringBody text
tagsstring[]Tags rendered as badges
urlstringURL opened on Enter
borderBorderStyleOverride default border style
actionCardActionAction 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 PropTypeDescription
titlestringEntry heading
subtitlestringOrganization or company
periodstringTime range
descriptionstringDetails
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).

PropTypeDescription
titlestringLarge heading
subtitlestringDescription
cta{ label, url }Call-to-action link
artstringCustom 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.

OptionTypeDefaultDescription
widthnumberfills available widthWidth in terminal cells (max 99)
heightnumberderived from aspectHeight in rows — a ceiling under fit: "contain"
maxHeightnumberpanel height, else 200Cap 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
altstringfile basenameShown in a bordered box on any failure
backgroundstringtheme backgroundHex composited under transparent pixels
invertbooleanfalseNegate the image’s ink (transparent areas keep the page background)
charsetstring" .:-=+*#%@"Ramp for the ascii and shading tiers
borderboolean | BorderStylefalseThemed border, adds 2 columns and 2 rows
resizablebooleanfalseViewer resizes the frame with +/-. Makes the block focusable, adds one hint row
fitPagebooleanfalseSize 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)

PropTypeDescription
idstringUnique input ID (required)
labelstringLabel text (required)
placeholderstringPlaceholder text
defaultValuestringInitial value
maxLengthnumberMax character count
validate(value) => string | nullReturn error message or null
maskbooleanMask input (for passwords)
transform(value) => stringTransform 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.

PropTypeDescription
idstringUnique input ID (required)
labelstringLabel text (required)
options{ label, value }[]Options array (required)
defaultValuestringInitial selected value
placeholderstringPlaceholder text
onChange(value) => voidChange 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.

Edit this page on GitHub