terminaltui — TUI Website & Application Framework
What It Is
terminaltui is a TypeScript framework that turns any website into a fully interactive terminal (TUI) experience. Projects use Next.js-style file-based routing — a config.ts for global settings plus a pages/ directory where each file is a route. The result is an interactive terminal app navigable by keyboard that can be published to npm so anyone can run it with npx my-site, or hosted over SSH so anyone can connect with ssh host -p PORT.
Quick Start
# Scaffold a new project
npx terminaltui init [template]
# Start dev preview
npx terminaltui dev
# Host over SSH (anyone connects with ssh)
npx terminaltui serve --port 2222
# Bundle for npm publish
npx terminaltui build
Minimal project:
// config.ts
import { defineConfig } from "terminaltui";
export default defineConfig({
name: "My Site",
theme: "cyberpunk",
});
// pages/home.ts
import { markdown } from "terminaltui";
export const metadata = { label: "Home", icon: "◆" };
export default function Home() {
return [markdown("Hello world!")];
}
Project structure:
my-site/
config.ts # theme, banner, global settings
pages/ # one file per route
home.ts
package.json # must have "type": "module"
tsconfig.json
File-Based Routing
terminaltui uses Next.js-style file-based routing. Each page is its own file, layouts nest automatically, and menus are auto-generated from the filesystem.
Project Structure
my-site/
├── config.ts # Theme, site name, global settings
├── pages/
│ ├── layout.ts # Root layout (wraps all pages)
│ ├── home.ts # Home page
│ ├── about.ts # /about
│ └── projects/
│ ├── index.ts # /projects
│ └── [slug].ts # /projects/:slug
├── api/
│ └── stats.ts # GET /api/stats
├── components/ # Reusable components
└── lib/ # Shared data/helpers
config.ts
Uses defineConfig() instead of createSite(). Contains only global settings — no pages, no content.
import { defineConfig } from "terminaltui";
export default defineConfig({
name: "My Site",
theme: "cyberpunk",
banner: { font: "ANSI Shadow" },
boot: { spinner: true },
// Optional: override auto-generated menu
menu: {
order: ["home", "projects", "about", "contact"],
labels: { projects: "Our Work", about: "About Us" },
// Items not listed are excluded from menu
},
// MenuConfig type:
// interface MenuConfig {
// items?: Array<{ label: string; page: string; icon?: string }>;
// order?: string[];
// labels?: Record<string, string>;
// icons?: Record<string, string>;
// exclude?: string[];
// }
// Lifecycle hooks
onInit: async () => { /* ... */ },
onExit: () => { /* ... */ },
onError: (err) => { /* ... */ },
});
Omit menu entirely to let the framework auto-generate it from pages/.
Page Files
Every .ts file in pages/ becomes a page. Default export is a function returning ContentBlock[].
// pages/about.ts
import { text, card } from "terminaltui";
export default function About() {
return [
card({ title: "About Me", body: "Full-stack developer based in..." }),
];
}
Metadata export — optional, controls menu label, order, transition, visibility:
// pages/projects/index.ts
import { card, row, col } from "terminaltui";
export const metadata = {
label: "Projects", // Menu label (default: filename titlecased)
order: 2, // Menu sort order (default: alphabetical)
transition: "slide", // Page transition
icon: "code", // Menu icon
hidden: false, // If true, excluded from auto-generated menu
};
export default function Projects() {
return [
row([
col([card({ title: "Project A" })], { span: 6 }),
col([card({ title: "Project B" })], { span: 6 }),
]),
];
}
Async pages — for data fetching:
// pages/dashboard/index.ts
import { card, row, col, text } from "terminaltui";
export default async function Dashboard() {
const stats = await fetch("/api/stats").then(r => r.json());
return [
row([
col([card({ title: "Revenue", content: [text(stats.revenue)] })], { span: 6 }),
col([card({ title: "Users", content: [text(String(stats.users))] })], { span: 6 }),
]),
];
}
Dynamic routes — [param] in filename, params passed to function:
// pages/projects/[slug].ts
import { card, text, badge } from "terminaltui";
export const metadata = { hidden: true }; // Dynamic routes excluded from menu
export default async function ProjectDetail({ params }: { params: { slug: string } }) {
const project = await fetch(`/api/projects/${params.slug}`).then(r => r.json());
return [
card({ title: project.name, content: [badge({ text: project.status }), text(project.description)] }),
];
}
Page Visibility
Control which pages appear in the menu:
metadata.hidden = true— page exists and is navigable but excluded from auto-generated menu- Pages without
hidden: trueappear in the menu by default - Dynamic route pages (
[slug].ts) should always behidden: true - In
defineConfig({ menu }):menu.order: ["home", "about"]— reorder the menu by page namemenu.exclude: ["secret"]— explicitly hide specific pagesmenu.items: [{ id, label, icon }]— fully manual menu (overrides auto-generation)
Layout Files
A layout.ts in any directory wraps all sibling and descendant pages. Receives children (the rendered page content).
// pages/layout.ts — Root layout, wraps everything
import { columns, panel, menu } from "terminaltui";
import type { ContentBlock } from "terminaltui";
export default function RootLayout({ children }: { children: ContentBlock[] }) {
return [
columns([
panel({ width: "25%", content: [menu({ source: "auto" })] }),
panel({ width: "75%", content: children }),
]),
];
}
// pages/dashboard/layout.ts — wraps /dashboard/* pages only
import { columns, panel, menu, text } from "terminaltui";
import type { ContentBlock } from "terminaltui";
export default function DashboardLayout({ children }: { children: ContentBlock[] }) {
return [
text("Dashboard"),
columns([
panel({ width: "20%", content: [menu({ items: [
{ label: "Overview", page: "dashboard" },
{ label: "Analytics", page: "dashboard/analytics" },
{ label: "Settings", page: "dashboard/settings" },
]})] }),
panel({ width: "80%", content: children }),
]),
];
}
Nesting: Layouts compose from outside in. For /dashboard/analytics:
RootLayout -> DashboardLayout -> AnalyticsPage
If no layout.ts exists at a level, pages use the nearest parent layout.
API Routes
Export named functions matching HTTP methods. File path maps to endpoint.
// api/stats.ts → GET /api/stats
export async function GET() {
return { revenue: "$1.2M", users: 45231 };
}
// api/contact.ts → POST /api/contact
export async function POST(request: { body: any }) {
const { name, email, message } = request.body;
return { success: true };
}
// api/projects/[id].ts → /api/projects/:id
export async function GET({ params }: { params: { id: string } }) {
return projects.find(p => p.id === params.id) ?? { error: "Not found" };
}
export async function DELETE({ params }: { params: { id: string } }) {
return { success: true };
}
Route mapping: api/stats.ts -> /api/stats, api/projects/[id].ts -> /api/projects/:id.
Auto-Generated Menu
When config.ts omits menu, the framework scans pages/ and builds the menu automatically.
Rules:
- Every
.tsfile directly inpages/becomes a top-level menu item - Directories with
index.tsbecome a top-level menu item (name from directory) - Sub-pages inside directories (other than
index.ts) are NOT in the top menu home.tsis always firstlayout.tsfiles are never menu itemsmetadata.hidden = truepages are excluded- Dynamic route files (
[param].ts) are excluded
Ordering: metadata.order (lowest first), then alphabetical for unordered items.
Labels: metadata.label > metadata.icon + titlecased filename > titlecased filename (about.ts -> "About", our-team.ts -> "Our Team").
Manual override in config.ts:
export default defineConfig({
name: "My Site",
menu: {
items: [
{ label: "Home", page: "home", icon: "terminal" },
{ label: "Work", page: "projects" },
{ label: "About Me", page: "about" },
],
},
});
menu() Component
Use menu({ source: "auto" }) in any page or layout to render the auto-generated menu:
import { hero, menu } from "terminaltui";
export default function Home() {
return [
hero({ title: "My Site", subtitle: "Welcome" }),
menu({ source: "auto" }), // Resolved at render time from pages/
];
}
Important: The framework renders the navigation menu automatically on the home screen. Do NOT add menu({ source: 'auto' }) to your home.ts -- it creates a duplicate menu.
If home.ts doesn't exist, the framework auto-generates a home page with hero() + menu({ source: "auto" }).
Focus & Scroll Model — CRITICAL FOR GOOD UX
TUI navigation is fundamentally up/down arrow keys moving a focus cursor between items. The viewport scrolls to follow the focused item. Understanding which components are focusable is essential for building good TUI experiences.
Default layout philosophy: Use vertical scrolling with flat card layouts. Use divider("Label") to visually separate sections rather than nesting them inside containers like tabs().
Focusability per Component
| Component | Focusable? | Behavior |
|---|---|---|
card() |
Yes — individually | Each card is a separate focus target. Best for browsable lists. |
link() |
Yes — individually | Opens URL on Enter. |
hero() |
Yes — individually | Opens CTA URL on Enter (if cta set). |
accordion() |
Yes — per item | Each accordion item is separately focusable. Enter toggles open/close. |
tabs() |
Yes — as one block | Enter cycles through tabs. Not ideal for many sections. |
textInput() |
Yes — individually | Enter starts editing, Escape exits. |
textArea() |
Yes — individually | Same as textInput but multi-line. |
select() |
Yes — individually | Enter opens dropdown, arrow keys pick option. |
checkbox() |
Yes — individually | Enter/Space toggles. |
toggle() |
Yes — individually | Enter/Space toggles. |
radioGroup() |
Yes — individually | Enter starts selection, arrows move between options. |
numberInput() |
Yes — individually | Left/Right changes value. |
searchInput() |
Yes — individually | Type to filter, arrows to pick result, Enter to select. |
chat() |
Yes — individually | Enter starts typing, sends message on Enter, Escape exits. |
button() |
Yes — individually | Enter triggers action. |
timeline() |
Yes — per item | Each timeline item is focusable but display-only (no action on Enter). |
markdown() |
No | Passive text. Not focusable. |
table() |
No | Passive data display. Not focusable. |
list() |
No | Passive list. Items not individually focusable. |
quote() |
No | Passive text. Not focusable. |
progressBar() / skillBar() |
No | Passive display. |
badge() |
No | Inline label. Not focusable. |
divider() |
No | Visual separator. Not focusable. |
spacer() |
No | Vertical spacing. Not focusable. |
image() |
No | Passive display. |
section() |
No — wrapper | Children inherit their own focusability. |
form() |
No — wrapper | Children (inputs, buttons) are individually focusable. |
dynamic() |
No — wrapper | Children inherit their own focusability. |
columns() |
No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
rows() |
No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
grid() |
No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
panel() |
No — wrapper | Used inside layout components. Children inherit focusability. |
TUI UX Patterns — What to Use When
| UX Need | Use | Avoid |
|---|---|---|
| Scrollable list of items | Flat card() blocks |
timeline(), list() |
| Sectioned long page | divider("Label") + cards below |
tabs() (forces horizontal switching) |
| Toggle between views of same data | tabs() |
n/a |
| Dense reference data | table() |
Many cards for tabular data |
| Expandable FAQ / details | accordion() |
Long markdown() blocks |
| Work history / education | Individual card() blocks with period as subtitle |
timeline() (items aren't actionable) |
| Skills / tech stack | skillBar() or list() (passive reference) |
Cards (overkill for simple data) |
| Dashboard with sidebar | columns() — sidebar panel + main panel |
Flat layout (loses spatial structure) |
| Monitoring grid | grid() with metric panels |
Single-column cards (wastes space) |
| Split editor/preview | columns([panel({…}), panel({…})]) |
Tabs (can't see both at once) |
| Log viewer + controls | rows() — controls on top, logs below |
Interleaved cards |
Bad → Good Patterns
// BAD: tabs for resume sections + timeline for entries
// timeline is one block, tabs force left/right switching
tabs([
{ label: "Experience", content: [timeline([
{ title: "Engineer", subtitle: "Acme", period: "2023–now" }
])] },
{ label: "Education", content: [timeline([...])] },
])
// GOOD: flat cards with divider sections — everything scrolls vertically
divider("Experience"),
card({ title: "Senior Engineer", subtitle: "Acme Corp — 2023–present", body: "Leading platform team..." }),
card({ title: "Junior Dev", subtitle: "Startup — 2021–2023", body: "Built core features..." }),
divider("Education"),
card({ title: "BS Computer Science", subtitle: "State University — 2021" }),
// Each card is focusable, everything scrolls naturally with ↑↓
When to use timeline(): Only when you want a visual connected-dot timeline aesthetic AND the items are passive (no action needed on Enter). For anything users need to browse, navigate, or interact with, use card() blocks instead.
When to use tabs(): Only for mutually exclusive views of the same data (e.g., "Grid view" vs "List view"). NOT for organizing sequential sections of a page — use divider("Label") for that. If two views should be visible simultaneously (e.g., Day 1 and Day 2 of a conference schedule), use columns([panel({…}), panel({…})]) instead.
Layout Mapping Guide
| Site Pattern | Layout | Example |
|---|---|---|
| Dashboard with sidebar navigation | columns() — narrow first panel (20-25%), wide main panel |
Server dashboard |
| Dashboard with multiple data views | columns() + nested grid() |
System monitor with CPU/Memory/Disk metrics |
| Pricing comparison (2-4 tiers) | columns() — one panel per tier |
SaaS pricing page |
| Side-by-side content (text + skills) | columns([panel({…}), panel({…})]) |
Portfolio about page |
| Day 1 / Day 2 schedule | columns([panel({…}), panel({…})]) |
Conference schedule |
| Food menu (categories) | columns([panel({…}), panel({…})]) — dishes left, drinks right |
Restaurant menu |
| Hours + location info | columns() — hours table left, address right |
Restaurant/shop hours |
| Project/portfolio cards | grid({ cols: 2 }) — cards in a grid |
Freelancer work page |
| Feature cards | grid({ cols: 2 }) |
SaaS features page |
| Speaker/team bios | grid({ cols: 2 }) |
Conference speakers |
| Sponsor logos by tier | grid({ cols: 3 }) per tier |
Conference sponsors |
| Log viewer | columns() — service list left, log stream right |
Server logs |
| Container table + details | rows([panel({…}), panel({…})]) — table top, details bottom |
Container management |
| Precise multi-column layout | row() + col() — 12-column grid system |
Complex dashboards |
| Responsive card grid | row() with xs:12, sm:6, lg:4 — cards reflow by terminal width |
Portfolio, features |
| Centered narrow content | container({ maxWidth: 80 }) — centered with max width |
Blog posts, forms |
12-Column Grid System
row(), col(), and container() provide a Bootstrap-style 12-column grid for precise layouts.
import { row, col, container } from "terminaltui";
// Basic: 2 equal columns (span:6 each = 50%)
row([
col([card({ title: "Left" })], { span: 6 }),
col([card({ title: "Right" })], { span: 6 }),
])
// 3-column layout: sidebar + main + aside
row([
col([menu], { span: 3 }), // 25%
col([mainContent], { span: 6 }), // 50%
col([aside], { span: 3 }), // 25%
])
// Responsive — cards reflow based on terminal width
row([
col([card1], { span: 4, sm: 6, xs: 12 }), // 33% wide, 50% medium, full narrow
col([card2], { span: 4, sm: 6, xs: 12 }),
col([card3], { span: 4, sm: 12, xs: 12 }),
], { gap: 1 })
// Container — centers content with max width
container([
row([
col([hero(...)], { span: 12 }), // full width
]),
row([
col([sidebar], { span: 3 }),
col([content], { span: 9 }),
]),
], { maxWidth: 100, padding: 2 })
ColConfig options: span (1-12), offset (0-11), xs/sm/md/lg (responsive spans), padding.
RowConfig options: gap (between cols, default: 1).
ContainerConfig options: maxWidth, padding, center (default: true).
Responsive breakpoints: xs (<60 cols), sm (60-89), md (90-119), lg (>=120).
Spatial navigation works automatically — arrow keys move between col content based on screen position.
Full API Reference
Every function below is imported from "terminaltui".
defineConfig(config): FileBasedConfig
Top-level project config. Default-export from config.ts.
interface FileBasedConfig {
name: string; // Required. Site name
handle?: string; // Handle shown on home (e.g. "@user")
tagline?: string; // Subtitle below the banner
banner?: BannerConfig; // ASCII art banner (use ascii() helper)
theme?: Theme | BuiltinThemeName; // Theme object or name. Default: "dracula"
borders?: BorderStyle; // Border style for cards/tables. Default: "rounded"
animations?: AnimationConfig; // Boot animation + exit message
navigation?: NavigationConfig; // Navigation behavior options
middleware?: MiddlewareFn[]; // Global middleware chain
easterEggs?: EasterEggConfig; // Konami code and custom commands
footer?: string | ContentBlock; // Footer content
statusBar?: boolean | StatusBarConfig; // Status bar configuration
menu?: MenuConfig; // Auto-menu overrides
serve?: ServeConfig; // SSH hosting config (see Hosting section)
env?: Record<string, unknown>; // Env defaults
artDir?: string | false; // Custom art directory path
// Lifecycle hooks
onInit?: (app: AppContext) => Promise<void> | void;
onExit?: (app: AppContext) => Promise<void> | void;
onNavigate?: (from: string, to: string, params?: RouteParams) => void;
onError?: (error: Error, context: ErrorContext) => ContentBlock[] | void;
}
// config.ts
export default defineConfig({
name: "My Site",
handle: "@me",
tagline: "a cool terminal site",
banner: ascii("My Site", { font: "ANSI Shadow", gradient: ["#ff6b6b", "#4ecdc4"] }),
theme: "dracula",
borders: "rounded",
animations: { boot: true, exitMessage: "Goodbye!", speed: "normal" },
middleware: [requireEnv(["API_KEY"])],
onInit: async (app) => { /* setup */ },
onError: (err, ctx) => [markdown(`Error: ${err.message}`)],
});
Page files
A page is any .ts file under pages/. Default-export a function returning content blocks; optionally export metadata.
// pages/about.ts
import { markdown, card } from "terminaltui";
export const metadata = {
label: "About Me", // menu label (default: title-cased filename)
icon: "◆", // single char shown before label
order: 2, // sort order in menu (lower first)
hidden: false, // hide from auto-menu (page still routable)
middleware: [/* ... */], // page-level middleware chain
};
export default function About() {
return [markdown("Hello!"), card({ title: "Hi", body: "..." })];
}
Common icons: "◆" "◈" "▣" "▤" "◉" "▸" "✦" "★" "●" "■" "▲" "♦"
Dynamic routes — pages/[param].ts
A bracketed filename creates a dynamic route. Params come in via the function arg:
// pages/projects/[slug].ts
export const metadata = { hidden: true };
export default async function Project({ params }: { params: { slug: string } }) {
const data = await fetchProject(params.slug);
return [card({ title: data.name, body: data.description })];
}
navigate(pageId: string, params?: RouteParams): void
Programmatic navigation from anywhere (event handlers, middleware, etc.).
navigate("home");
navigate("projects/[slug]", { slug: "my-app" });
Content Blocks
markdown(text: string): TextBlock
Renders text with markdown formatting (bold, italic, inline code, code blocks).
markdown("This is **bold** and *italic* with `code`.")
card(config): CardBlock
A bordered card with title, optional subtitle, body, tags, URL, and action.
interface CardBlock {
title: string; // Card heading
subtitle?: string; // Secondary text (price, date, star count)
body?: string; // Body text
tags?: string[]; // Tags shown as badges
url?: string; // URL opened on Enter
border?: BorderStyle; // Override border style
action?: CardAction; // Action on select (navigate, onPress, etc.)
}
interface CardAction {
label?: string;
style?: "primary" | "secondary" | "danger";
confirm?: string; // Confirmation prompt text
onPress?: () => void | Promise<void>;
navigate?: string; // Navigate to a page/route
params?: RouteParams; // Route parameters
}
card({
title: "My Project",
subtitle: "★ 200",
body: "A brief description.",
tags: ["TypeScript", "Open Source"],
url: "https://github.com/user/repo",
action: { navigate: "project", params: { name: "my-project" } },
})
List-to-Detail navigation pattern: Use action.navigate on cards to link to detail pages. Mark detail pages as hidden so they don't appear in the menu.
// List page (pages/blog.ts)
export default function Blog() {
return [
card({ title: "First Post", action: { navigate: "blog-1" } }),
card({ title: "Second Post", action: { navigate: "blog-2" } }),
];
}
// Detail page (pages/blog-1.ts)
export const metadata = { hidden: true };
export default function BlogPost1() {
return [card({ title: "First Post", body: "Full content here..." })];
}
timeline(items: TimelineItem[]): TimelineBlock
Vertical timeline with connected entries. Great for work history, changelog, education.
interface TimelineItem {
title: string; // Entry heading
subtitle?: string; // Organization/company
period?: string; // Time range
description?: string; // Details
}
timeline([
{ title: "Senior Engineer", subtitle: "Acme Corp", period: "2023 — present", description: "Leading platform team" },
{ title: "BS Computer Science", subtitle: "University", period: "2017 — 2021" },
])
table(headers: string[], rows: string[][]): TableBlock
A bordered data table.
table(
["Plan", "Price", "Features"],
[
["Free", "$0/mo", "Basic features"],
["Pro", "$10/mo", "Everything + priority support"],
]
)
list(items: string[], style?): ListBlock
A styled list. Style: "bullet" (default) | "number" | "dash" | "check" | "arrow".
list(["First item", "Second item", "Third item"], "check")
quote(text: string, attribution?: string): QuoteBlock
Block quote with optional attribution.
quote("The best way to predict the future is to invent it.", "— Alan Kay")
hero(config): HeroBlock
Large hero section with title, subtitle, CTA, and optional ASCII art.
interface HeroBlock {
title: string; // Large heading
subtitle?: string; // Description
cta?: { label: string; url: string }; // Call-to-action link
art?: string; // Custom ASCII art string
}
hero({ title: "Welcome", subtitle: "Build terminal apps.", cta: { label: "Get Started →", url: "https://..." } })
gallery(items): GalleryBlock
Grid of cards. Items use the same shape as card() (without type).
gallery([
{ title: "Photo 1", body: "Description", tags: ["nature"] },
{ title: "Photo 2", body: "Description", tags: ["urban"] },
])
tabs(items): TabsBlock
Tabbed content. Each tab has a label and nested content blocks.
tabs([
{ label: "Frontend", content: [list(["React", "Vue", "Svelte"], "check")] },
{ label: "Backend", content: [list(["Node.js", "Python", "Go"], "check")] },
])
accordion(items): AccordionBlock
Collapsible sections. Same shape as tabs. Great for FAQs.
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: string, url: string, options?: LinkOptions): LinkBlock
A clickable link. Opens in the user's browser when selected.
interface LinkOptions {
icon?: string; // Icon character before the label
}
link("GitHub", "https://github.com/user")
link("Email", "mailto:hello@example.com", { icon: "✉" })
progressBar(label: string, value: number, max?: number): ProgressBarBlock
Generic progress bar. Max defaults to 100. Always shows percent.
progressBar("Project Alpha", 7, 10)
progressBar("Completion", 65)
skillBar(label: string, value: number): ProgressBarBlock
Shorthand for progressBar(label, value, 100) with showPercent: true.
skillBar("TypeScript", 90)
skillBar("Rust", 75)
badge(text: string, color?: string): BadgeBlock
An inline badge/tag. Color is a hex string.
badge("v2.0")
badge("NEW", "#50fa7b")
image(path: string, options?): ImageBlock
Renders an image in the terminal.
image("./logo.png")
image("./photo.jpg", { width: 60, mode: "braille" })
Options: width?: number, mode?: "ascii" | "braille" | "blocks".
section(title: string, content: ContentBlock[]): SectionBlock
Groups content under a titled section header with a divider line.
section("Appetizers", [
card({ title: "Bruschetta", subtitle: "$12", body: "Toasted bread with tomatoes" }),
])
divider(style?, label?): DividerBlock
Horizontal divider line. Styles: "solid" | "dashed" | "dotted" | "double" | "label". If the first arg is not a known style, it becomes a label automatically.
divider() // solid line
divider("dashed") // dashed line
divider("My Section") // labeled divider (auto-detected)
divider("label", "Section") // explicit label style
spacer(lines?: number): SpacerBlock
Vertical whitespace. Defaults to 1 line.
spacer() // 1 blank line
spacer(3) // 3 blank lines
dynamic(renderFn) / dynamic(deps, renderFn): DynamicBlock
Reactive content block that re-renders when state changes. Currently all dynamic blocks re-render on any state change. The deps array is accepted for forward compatibility.
// Re-renders on any state change
dynamic(() => markdown(`Count: ${state.get("count")}`))
// Deps accepted for forward compatibility (currently re-renders on any change)
dynamic(["count"], () => markdown(`Count: ${state.get("count")}`))
asyncContent(config): AsyncContentBlock
Lazily-loaded async content.
asyncContent({
load: async () => {
const data = await fetchData();
return [card({ title: data.name, body: data.description })];
},
loading: "Loading data...",
fallback: [markdown("Failed to load.")],
})
Box Model
Every component uses a unified box model via computeBoxDimensions() from src/layout/box-model.ts. One function, one contract, one source of truth for width calculations.
+---------------- allocated width -----------------+
| margin |
| +------------ outer width -----------------+ |
| | border | |
| | +-------- inner width ---------------+ | |
| | | padding | | |
| | | +---- content width -----------+ | | |
| | | | | | | |
| | | | Text wraps here. | | | |
| | | | Children render here. | | | |
| | | | | | | |
| | | +------------------------------+ | | |
| | +------------------------------------+ | |
| +-------------------------------------------+ |
+--------------------------------------------------+
content = allocated - (margin * 2) - (border * 2) - (padding * 2)
Width cascade:
Terminal width (e.g. 120 cols)
-> createRenderContext(): ctx.width = Math.min(terminalWidth, 100)
-> renderContentPage(): blockWidth = ctx.width - 1 (focus prefix)
-> Component gets blockWidth as ctx.width
-> dims = computeBoxDimensions(ctx.width, COMPONENT_DEFAULTS.componentType)
-> Text wraps at dims.content
-> Child blocks receive dims.content as their width
API:
import { computeBoxDimensions, COMPONENT_DEFAULTS } from "terminaltui";
import type { BoxDimensions, BoxOptions } from "terminaltui";
const dims = computeBoxDimensions(80, { border: true, padding: 1 });
// dims.content = 76 (80 - 2 border - 2 padding)
// Using component defaults
const cardDims = computeBoxDimensions(80, COMPONENT_DEFAULTS.card);
// cardDims.content = 76
// Override per-instance
const widePad = computeBoxDimensions(80, { ...COMPONENT_DEFAULTS.card, padding: 2 });
// widePad.content = 74
Defaults quick reference:
| Component | Border | Padding | Margin | Chrome | Content at w=80 |
|---|---|---|---|---|---|
| card | 1 | 1 | 0 | 4 | 76 |
| text | 0 | 0 | 0 | 0 | 80 |
| hero | 0 | 0 | 0 | 0 | 80 |
| table | 1 | 0 | 0 | 2 | 78 |
| quote | 1 | 1 | 1 | 6 | 74 |
| timeline | 1 | 1 | 1 | 6 | 74 |
| accordion | 0 | 2 | 0 | 4 | 76 |
| tabs | 0 | 2 | 0 | 4 | 76 |
| textInput | 1 | 1 | 0 | 4 | 76 |
| select | 1 | 1 | 0 | 4 | 76 |
| button | 1 | 2 | 1 | 8 | 72 |
| badge | 0 | 0 | 0 | 0 | 80 |
| progressBar | 0 | 0 | 0 | 0 | 80 |
| divider | 0 | 0 | 0 | 0 | 80 |
| image | 1 | 0 | 0 | 2 | 78 |
Rules:
- Every component calls
computeBoxDimensions(). No exceptions. - Layout components (columns, rows, grid, panel, row, col, container) divide width among children — they do NOT call
computeBoxDimensions()for themselves. - Text always wraps at
dims.content. - Child blocks receive
dims.contentas their allocated width. - No manual
ctx.width - Nin component files. All chrome subtraction goes through the box model.
Layout Components
Layout components divide the terminal into panels — side-by-side, stacked, or in grids. Each panel is an independent area with its own content. Panels can have borders, titles, and content clipping.
Navigation: Tab/Shift+Tab switches between panels. Arrow keys navigate within the active panel. The active panel gets an accent-colored border.
Responsive: If the terminal is too narrow for side-by-side panels (<20 chars per panel), columns automatically collapse to vertical stacking.
columns(panels: PanelConfig[]): ColumnsBlock
Side-by-side panels. Each panel gets a width (percentage, fixed chars, or auto).
columns([
panel({ width: "60%", content: [
table(["Name", "Status"], [["nginx", "running"], ["postgres", "running"]]),
]}),
panel({ width: "40%", content: [
markdown("## Stats"),
progressBar("CPU", 45),
progressBar("Memory", 72),
]}),
])
rows(panels: PanelConfig[]): RowsBlock
Vertically stacked panels with fixed/flex heights.
rows([
panel({ height: "30%", content: [
markdown("## Active Containers"),
table(["Name", "Status"], [["nginx", "up"], ["postgres", "up"]]),
]}),
panel({ height: "70%", content: [
markdown("## Logs"),
markdown("12:00:01 [nginx] GET /health 200"),
markdown("12:00:02 [nginx] GET /users 200"),
]}),
])
Deprecated:
split({ direction, ratio, first, second })still works but is now a thin wrapper that returns acolumns()(horizontal) orrows()(vertical) block. Will be removed in v2.0. Prefer the explicit form:columns([panel({ width: "30%", content: first }), panel({ width: "70%", content: second })]).
grid(config: GridConfig): GridBlock
N×M grid of panels. cols: number of columns. gap: character gap between cells (default 1).
grid({
cols: 2,
gap: 1,
items: [
panel({ title: "CPU", content: [progressBar("Usage", 45)] }),
panel({ title: "Memory", content: [progressBar("RAM", 72)] }),
panel({ title: "Disk", content: [progressBar("Usage", 31)] }),
panel({ title: "Network", content: [markdown("125 Mbps")] }),
],
})
panel(config: PanelConfig): PanelBlock
A single panel with optional border, title, padding, and content clipping. Used inside columns(), rows(), grid(), or standalone.
interface PanelConfig {
content: ContentBlock[];
width?: string | number; // "50%", "40%", 30 (chars). For columns.
height?: string | number; // "50%", "40%", 10 (rows). For rows.
title?: string; // Title in the top border
border?: boolean | BorderStyle; // Show border (default: true in layouts)
padding?: number; // Interior padding (default: 0)
scrollable?: boolean; // Independent scrolling (default: true)
focusable?: boolean; // Can receive focus (default: true if has focusable content)
}
Nested Layouts
Layouts can be nested for complex dashboards:
columns([
panel({ width: "25%", title: "Navigation", content: [
link("Dashboard", "#"),
link("Logs", "#"),
link("Settings", "#"),
]}),
panel({ width: "75%", content: [
rows([
panel({ height: "60%", content: [
markdown("## Main Content"),
table(["Name", "Status"], [["nginx", "running"]]),
]}),
panel({ height: "40%", title: "Logs", content: [
markdown("Log output here..."),
]}),
]),
]}),
])
Sizing Reference
| Context | Property | Values |
|---|---|---|
| columns | width |
"50%", 30 (chars), "auto" (default: equal split) |
| rows | height |
"50%", 10 (rows), "auto" (default: equal split) |
| grid | cols |
Number of columns |
| grid | gap |
Gap in characters (default: 1) |
container(content: ContentBlock[], config?): ContainerBlock
Wrap content in a centered container with an optional max width and padding. Use as the outermost wrapper of a page when you want a Bootstrap-style centered layout.
container([
hero({ title: "Welcome" }),
row([
col([card({ title: "Left" })], { span: 6 }),
col([card({ title: "Right" })], { span: 6 }),
]),
], { maxWidth: 100, padding: 2, center: true })
interface ContainerConfig {
maxWidth?: number; // Max width in columns (default: terminal width)
padding?: number; // Horizontal padding (default: 0)
center?: boolean; // Center the container (default: true)
}
row(cols: ColBlock[], config?): RowBlock
A 12-column grid row. Children must be col(...) blocks. Rows auto-wrap when the sum of effective spans exceeds 12 at the current breakpoint.
row([
col([statsCard], { span: 3, xs: 12 }),
col([chartCard], { span: 9, xs: 12 }),
], { gap: 1 })
interface RowConfig {
gap?: number; // Spacing between cols, in chars (default: 1)
}
col(content: ContentBlock[], config: ColConfig): ColBlock
A 12-column grid cell. span is required; xs/sm/md/lg override span at each breakpoint.
col([card({ title: "Stats" })], {
span: 4,
offset: 0,
xs: 12, sm: 6, md: 4, lg: 3,
})
interface ColConfig {
span: number; // 1-12. Width as a fraction of 12 columns.
offset?: number; // 0-11. Empty columns to the left.
padding?: number; // Interior padding
xs?: number; // Override span for xs (<60 cols)
sm?: number; // Override span for sm (60-89)
md?: number; // Override span for md (90-119)
lg?: number; // Override span for lg (≥120)
}
Breakpoints: xs (<60 cols), sm (60-89), md (90-119), lg (≥120). Spatial navigation works automatically across grid cells.
Removed in this release:
box(). For a bordered region, usepanel({ border: true, padding: 1, content: […] }). For padding/margin only, usecontainer({ padding: 1, content: […] }).
menu(config: MenuConfig): MenuBlock
Inline menu block. The auto source resolves at render time from the file-based router's discovered pages.
import { menu } from "terminaltui";
menu({ source: "auto" })
menu({
source: "manual",
items: [
{ id: "home", label: "Home", icon: "◆" },
{ id: "projects", label: "Projects", icon: "▣" },
{ id: "contact", label: "Contact", icon: "◉" },
],
})
interface MenuConfig {
source: "auto" | "manual";
items?: MenuItemConfig[]; // required when source === "manual"
}
interface MenuItemConfig {
id: string; // page id or route
label: string;
icon?: string;
hidden?: boolean;
}
The framework already renders the home menu automatically. Don't add
menu({ source: "auto" })topages/home.ts— it'll duplicate the menu.
Input Components
All input components create interactive form elements. In navigation mode, press Enter on an input to enter edit mode; press Escape to return to navigation.
textInput(config): TextInputBlock
interface TextInputBlock {
id: string; // Unique input ID
label: string; // Label text
placeholder?: string; // Placeholder text
defaultValue?: string; // Initial value
maxLength?: number; // Max character count
validate?: (value: string) => string | null; // Return error message or null
mask?: boolean;
…(truncated)