Components

Reference for all React components in @visulima/tui: Alert, Badge, Box, Code, ConsoleOverlay, FilePicker, Text, BigText, ConfirmInput, DiffView, Gradient, Help, Link, Markdown, MultiSelect, Newline, OrderedList, Paginator, Slider, Spacer, Static, StatusMessage, Stopwatch, Tab, Tabs, Textarea, TextInput, Timer, Transform, TreeView, Spinner, ProgressBar, SelectInput, Table, UnorderedList

Components

@visulima/tui exposes Ink-compatible core components plus native renderer additions.

import { Box, Cursor, Newline, Spacer, Static, Text, Transform } from "@visulima/tui";
import { BigText, ConfirmInput, FilePicker, Gradient, Help, MultiSelect, Paginator, ProgressBar, SelectInput, Spinner, Stopwatch, Tab, Table, Tabs, TextInput, Timer } from "@visulima/tui";

Box

Layout primitive backed by Yoga.

<Box
    flexDirection="row" // row | column | row-reverse | column-reverse
    flexGrow={1}
    flexShrink={1}
    width={40}
    height={10}
    minWidth={10}
    minHeight={4}
    padding={1}
    paddingX={2}
    margin={1}
    gap={1}
    alignItems="center"
    justifyContent="space-between"
    borderStyle="round" // single | double | round | bold | classic | singleDouble | doubleSingle
    borderColor="cyan"
    borderBackgroundColor="blue"
    borderTop={true}
    borderRight={true}
    borderBottom={true}
    borderLeft={true}
>
    {children}
</Box>

Overflow and CSS-Level Scrolling

Box supports CSS-level scrolling via overflow: 'scroll'. This is separate from the component-based ScrollView and provides native-style overflow clipping with integrated scrollbar rendering.

<Box
    overflow="scroll" // visible | hidden | scroll
    overflowX="hidden" // per-axis overflow control
    overflowY="scroll" // per-axis overflow control
    scrollTop={0} // vertical scroll position (rows)
    scrollLeft={0} // horizontal scroll position (columns)
    scrollbarThumbColor="gray" // scrollbar thumb color
    scrollbar={true} // show/hide scrollbar (default: true)
    height={10}
    width={40}
>
    {longContent}
</Box>

Sticky Headers

Elements within a scrollable Box can be pinned to the top or bottom of the viewport using the sticky prop. A sticky header remains pinned only while its parent section is visible.

<Box overflow="scroll" height={15}>
    <Box sticky>
        <Text bold>Section A</Text>
    </Box>
    {sectionAContent}

    <Box sticky>
        <Text bold>Section B</Text>
    </Box>
    {sectionBContent}
</Box>
PropTypeDescription
stickyboolean | 'top' | 'bottom'Pin this element during scroll. true and 'top' pin to the top; 'bottom' pins to the bottom.
opaquebooleanMarks this element as opaque for rendering optimization (not yet active).
scrollbarbooleanShow/hide the scrollbar for scrollable elements. Defaults to true.

Notes:

  • Box defaults to flexDirection="row" unless you set it explicitly.
  • Border side toggles (borderTop, etc.) are supported.
  • Per-side border colors are supported: borderTopColor, borderBottomColor, borderLeftColor, borderRightColor.
  • Per-side border background colors are supported: borderTopBackgroundColor, borderBottomBackgroundColor, borderLeftBackgroundColor, borderRightBackgroundColor.
  • Per-side dim is supported: borderTopDimColor, borderBottomDimColor, borderLeftDimColor, borderRightDimColor.

Text

Renders styled text.

<Text
    color="cyan" // named, #rgb, #rrggbb, rgb(r,g,b), or ANSI index 0-255
    backgroundColor="blue"
    bold
    italic
    underline
    strikethrough
    dim
    inverse
>
    Hello world
</Text>

dimColor is supported as an Ink-compat alias for dim.

Cursor

Declaratively position the terminal cursor. Two modes are available:

Inline mode (no props)

Place <Cursor /> after a <Text> node. The cursor appears where the preceding text ended, even when text wraps across lines.

<Box>
    <Text>{prompt + value}</Text>
    <Cursor />
</Box>

This is the recommended approach for text inputs. It handles text wrapping, wide characters (CJK, emoji), and multi-line output automatically because the cursor position is derived from the actual rendered text output.

For editable text with a cursor position in the middle:

<Box>
    <Text>{value.slice(0, cursorPos)}</Text>
    <Cursor />
    <Text>{value.slice(cursorPos)}</Text>
</Box>

Anchor mode

Pass anchorRef and/or x/y to position the cursor relative to another element's content origin.

const ref = useRef(null);

<Box ref={ref}>
    <Text>{content}</Text>
</Box>
<Cursor anchorRef={ref} x={5} y={1} />
PropTypeDefault
anchorRefRefObject<DOMElement/null>parent node
xnumber0
ynumber0

Notes:

  • <Cursor> must not be rendered inside <Text>.
  • If multiple <Cursor> components are rendered, the last one controls the terminal cursor.
  • If anchorRef is set but unresolved, the cursor is hidden for that frame.
  • See also useCursor() for imperative cursor control (e.g. IME support).

Code

Syntax-highlighted code display using Shiki. Supports 25+ languages with lazy loading and VS Code-quality tokenization.

import { Code } from "@visulima/tui";

<Code code='const x = "hello";' language="javascript" />
<Code code={source} language="python" showLineNumbers startLine={10} />
PropTypeDefaultDescription
codestring(required)Source code to highlight
languagestringProgramming language (e.g. "typescript")
themestring"github-dark-default"Shiki theme name
showLineNumbersbooleanfalseShow line number gutter
startLinenumber1Starting line number (for excerpts)
highlightLinesReadonlySet<number>Line numbers to visually emphasize

Supported languages: javascript, typescript, jsx, tsx, json, html, css, python, ruby, rust, go, java, bash, shell, sql, markdown, yaml, toml, xml, svelte, vue, and more. Unknown languages fall back to plain text.

The highlighter loads asynchronously — the first render shows plain text, then re-renders with colors once Shiki is ready.

DiffView

Display file differences with colored additions/deletions. Supports unified and split (side-by-side) views with optional inline character-level diff highlighting.

import { DiffView } from "@visulima/tui";

<DiffView oldText={original} newText={modified} />
<DiffView diff={unifiedDiffString} mode="split" />
<DiffView oldText={a} newText={b} oldLabel="v1.0" newLabel="v2.0" inlineDiff />
PropTypeDefaultDescription
oldTextstring""Original text (left / old)
newTextstring""Modified text (right / new)
diffstringPre-computed unified diff (overrides oldText/newText)
mode"unified" | "split""unified"Display mode
showLineNumbersbooleantrueShow line numbers
contextnumber3Unchanged context lines around changes
inlineDiffbooleantrueCharacter-level highlighting within changed lines
oldLabelstring"old"Label for the old version
newLabelstring"new"Label for the new version
languagestringProgramming language for syntax highlighting
themestring"github-dark-default"Shiki theme for syntax highlighting

Syntax highlighting in diffs

When language is provided, diff content is highlighted with Shiki (same engine as the Code component). Syntax colors are preserved for context lines, and overlaid with diff colors (red/green) for changed lines.

<DiffView oldText={oldCode} newText={newCode} language="typescript" />

Colors: additions in green (+), deletions in red (-), context lines dimmed. Inline diff highlights changed characters with inverted background.

FilePicker

A file system browser for picking files and navigating directories in the terminal.

import { FilePicker } from "@visulima/tui";

<FilePicker
    onSelect={(entry) => console.log("Selected:", entry.path)}
    filter={{ extensions: [".ts", ".tsx"] }}
/>
<FilePicker
    initialPath="/home/user/projects"
    showSize
    showPermissions
    limit={15}
    onCancel={() => exit()}
/>
PropTypeDefaultDescription
initialPathstringprocess.cwd()Starting directory
filterFilePickerFilter{}File filtering options (see below)
onSelect(entry: FileEntry) => voidCalled when a file is selected (Enter)
onCancel() => voidCalled when Escape is pressed
showPermissionsbooleanfalseShow file permissions column
showSizebooleanfalseShow file size column
isFocusedbooleantrueWhether keyboard navigation is active
limitnumber10Visible items (viewport height)
accentColorstring"cyan"Focused item highlight color
directoryColorstring"blue"Directory name color
fileColorstring"white"File name color

Filter options

FieldTypeDefaultDescription
extensionsstring[]Allowed extensions (e.g. [".ts", ".json"])
showHiddenbooleanfalseInclude hidden files (dotfiles)
showDirectoriesbooleantrueInclude directories
showFilesbooleantrueInclude regular files

FileEntry shape

type FileEntry = {
    name: string;
    path: string;
    isDirectory: boolean;
    isHidden: boolean;
    size: number;
    permissions: string;
};

Keyboard

KeyAction
/ Move focus
EnterOpen directory or select file
Backspace / Go to parent directory
EscapeCancel (onCancel)
Page Up/DownJump by viewport height
Home / EndJump to first/last entry
.Refresh directory listing

BigText

Render large ASCII art text using CFonts. Ported from ink-big-text.

import { BigText } from "@visulima/tui";

<BigText text="Hello" />
<BigText text="Cool" font="chrome" colors={["red", "blue"]} />
PropTypeDefault
textstringrequired
fontFont'block'
align'left' | 'center' | 'right''left'
colorsstring[]['system']
backgroundColorBackgroundColor'transparent'
letterSpacingnumber1
lineHeightnumber1
spacebooleantrue
maxLengthnumber0

Available fonts: block, slick, tiny, grid, pallet, shade, simple, simpleBlock, 3d, simple3d, chrome, huge.

Gradient

Apply a terminal color gradient to child text. Ported from ink-gradient, uses @visulima/colorize internally.

import { Gradient, Text } from "@visulima/tui";

<Gradient name="rainbow">
    <Text>Hello, World!</Text>
</Gradient>

<Gradient colors={["#ff0000", "#00ff00", "#0000ff"]}>
    <Text>Custom gradient</Text>
</Gradient>
PropTypeDefault
nameGradientName
colorsGradientColors

name and colors are mutually exclusive — exactly one must be provided.

Built-in presets: rainbow, cristal, teen, mind, morning, vice, passion, fruit, instagram, atlas, retro, summer, pastel.

Help

Auto-generated keybinding help bar. Renders key-description pairs from an array of KeyBinding definitions (typically returned by useKeyBindings).

import { Help, useKeyBindings } from "@visulima/tui";

const { bindings } = useKeyBindings([
    { binding: { key: "q", description: "Quit" }, handler: () => exit() },
    { binding: { key: ["↑", "k"], description: "Up", group: "Navigation" }, handler: () => moveUp() },
]);

<Help bindings={bindings} />
<Help bindings={bindings} mode="full" />
PropTypeDefaultDescription
bindingsKeyBinding[](required)Key bindings to display
mode"short" | "full""short"Short = single line; full = multi-column grid
separatorstring" · "Separator between pairs in short mode
keyColorstring"cyan"Color for key labels
descriptionColorstringColor for descriptions (defaults to dim)
maxColumnsnumber3Max columns in full mode

Short mode

Renders all bindings on a single line, truncated to terminal width:

q Quit · ↑/k Up · ↓/j Down · enter Select

Full mode

Groups bindings by the group field into columns:

Navigation          Actions
↑/k Up              enter Select
↓/j Down            q Quit

Key names are automatically humanized: "upArrow""↑", "return""enter", "escape""esc", etc.

Create clickable hyperlinks in the terminal using OSC 8 sequences. Ported from ink-link.

import { Link, Text } from "@visulima/tui";

<Link url="https://example.com">
    <Text color="cyan">My Website</Text>
</Link>

// Custom fallback for unsupported terminals
<Link url="https://example.com" fallback={(text, url) => `[${text}](${url})`}>
    <Text>Docs</Text>
</Link>
PropTypeDefaultDescription
urlstringThe URL to link to (required)
fallbackboolean | ((text: string, url: string) => string)trueFallback for unsupported terminals
childrenReactNodeLink text content

When fallback is true, unsupported terminals show: My Website https://example.com. When false, only the text is shown. A function receives (text, url) for custom formatting.

Supported terminals.

Markdown

Render Markdown content as terminal UI elements. Parses with marked and maps tokens to Ink components. Code blocks are syntax-highlighted via the Code component.

import { Markdown } from "@visulima/tui";

<Markdown>{"# Hello World\n\nThis is **bold** and *italic*."}</Markdown>
<Markdown codeTheme="github-dark-default">{"```js\nconst x = 1;\n```"}</Markdown>
PropTypeDefaultDescription
childrenstring(required)Markdown source string
codeThemestring"github-dark-default"Shiki theme for code blocks
maxWidthnumberterminal widthMaximum text wrap width
streamingbooleanfalseProgressive rendering for LLM output

Streaming mode

Enable streaming when rendering Markdown that arrives incrementally (e.g., token-by-token from an AI model). The component handles incomplete Markdown gracefully — unclosed code fences and partial blocks render as text until they close.

const [text, setText] = useState("");
// ... text grows as tokens arrive ...
<Markdown streaming>{text}</Markdown>;

Supported Markdown features

  • Headings (H1-H6) — bold, color-coded by depth
  • Paragraphs — with word wrapping
  • Code blocks — syntax highlighted via \<Code> component
  • Inline code — rendered with inverse styling
  • Bold, italic, strikethrough
  • Links — rendered via the existing \<Link> component (OSC 8)
  • Ordered and unordered lists — via existing \<OrderedList> / \<UnorderedList>
  • Blockquotes — with left border
  • Horizontal rules
  • Tables — via existing \<Table> component
  • Images — displayed as [image: alt text] (terminals cannot render images)

Newline

<Newline />
<Newline count={2} />

Spacer

<Box flexDirection="row">
    <Text>Left</Text>
    <Spacer />
    <Text>Right</Text>
</Box>

Static

Append-only region for completed items and log lines.

<Static items={completedTasks}>
    {(task) => (
        <Box key={task.id}>
            <Text color="green">✓ {task.name}</Text>
        </Box>
    )}
</Static>

Static only appends new tail items from items.

Transform

Applies a transform function to the collected text output of its subtree.

<Transform transform={(s) => s.toUpperCase()}>
    <Text>hello world</Text>
</Transform>

Spinner

<Spinner />
<Spinner color="cyan" />
<Spinner frames={["-", "\\", "|", "/"]} interval={100} />

Props (plus all Text props):

PropTypeDefault
framesstring[]Braille frames
intervalnumber80

Stopwatch

A count-up elapsed time display. Exposes start, stop, toggle, reset, and lap controls via ref.

import { Stopwatch } from "@visulima/tui";
import { useRef } from "react";

const ref = useRef(null);

<Stopwatch ref={ref} autoStart />
<Stopwatch autoStart color="green" bold />
<Stopwatch format={(ms) => `${(ms / 1000).toFixed(1)}s`} />
PropTypeDefaultDescription
autoStartbooleanfalseStart counting immediately
intervalnumber1000Tick interval in milliseconds
format(elapsed: number) => stringMM:SS/HH:MM:SSCustom time formatter
colorstringText color
boldbooleanBold text

Ref methods

ref.current.start();    // Start counting
ref.current.stop();     // Pause
ref.current.toggle();   // Toggle start/stop
ref.current.reset();    // Reset to 00:00 and stop
ref.current.lap();      // Capture current elapsed time

For headless usage, use the useStopwatch hook directly.

Timer

A countdown timer display. Exposes start, stop, toggle, and reset controls via ref.

import { Timer } from "@visulima/tui";
import { useRef } from "react";

const ref = useRef(null);

<Timer ref={ref} duration={60_000} autoStart />
<Timer duration={300_000} autoStart color="red" onTimeout={() => alert("Done!")} />
PropTypeDefaultDescription
durationnumber(required)Countdown duration in milliseconds
autoStartbooleanfalseStart counting immediately
intervalnumber1000Tick interval in milliseconds
onTimeout() => voidCalled when the timer reaches zero
format(remaining: number) => stringMM:SS/HH:MM:SSCustom time formatter
colorstringText color
boldbooleanBold text

Ref methods

ref.current.start();    // Start countdown
ref.current.stop();     // Pause
ref.current.toggle();   // Toggle start/stop
ref.current.reset();    // Reset to original duration and stop

For headless usage, use the useTimer hook directly.

ProgressBar

A terminal progress bar that fills proportionally to a percentage value. Ported from ink-progress-bar.

import { ProgressBar } from "@visulima/tui";

<ProgressBar percent={0.5} />
<ProgressBar percent={0.75} color="green" left={10} right={5} />
<ProgressBar percent={1} character="▓" rightPad />

All Text props (color, bold, dimColor, etc.) are forwarded to the underlying \<Text>.

PropTypeDefaultDescription
percentnumber1Completion between 0 and 1 (clamped)
columnsnumber0Override terminal width (0 = auto-detect)
leftnumber0Columns reserved on the left (e.g. for labels)
rightnumber0Columns reserved on the right
characterstring'█'Fill character for the completed portion
rightPadbooleanfalsePad remaining space with whitespace

Example with labels:

<Box>
    <Text>Progress: </Text>
    <ProgressBar percent={0.6} left={10} right={5} color="cyan" />
    <Text> 60%</Text>
</Box>

Slider

A keyboard-driven range input for selecting numeric values. Supports horizontal and vertical orientations.

import { Slider } from "@visulima/tui";

<Slider defaultValue={50} onChange={(v) => console.log(v)} />
<Slider defaultValue={22} min={10} max={40} step={1} accentColor="red" width={30} />
<Slider defaultValue={60} orientation="vertical" width={8} />
PropTypeDefaultDescription
valuenumberControlled value
defaultValuenumber0Initial value (uncontrolled)
minnumber0Minimum value
maxnumber100Maximum value
stepnumber1Increment per arrow key press
orientation"horizontal" | "vertical""horizontal"Layout direction
widthnumber20Track width in columns (or rows if vertical)
isFocusedbooleantrueWhether the component responds to input
isDisabledbooleanfalseIgnores all input and dims the slider
filledCharacterstring"█"Character for the filled portion
emptyCharacterstring"░"Character for the empty portion
thumbCharacterstring"●"Character marking the current position
accentColorstring"green"Color for filled portion and thumb
defaultColorstring"gray"Color for empty portion
onChange(value: number) => voidCalled when value changes

Keyboard

  • Left/Right arrows (horizontal) or Up/Down (vertical): move by step
  • Home/End: jump to min/max
  • Page Up/Down: move by 10 × step
  • 0–9: jump to 0%, 10%, … 90% of the range

Controlled mode

const [value, setValue] = useState(50);
<Slider value={value} onChange={setValue} />;

ConsoleOverlay

A dockable panel that captures console.log/warn/error/debug output and displays it within the TUI. Similar to browser devtools console.

import { Box, ConsoleOverlay } from "@visulima/tui";

<Box flexDirection="column" height="100%">
    <Box flexGrow={1}>{/* main app content */}</Box>
    <ConsoleOverlay dock="bottom" height={6} />
</Box>;
PropTypeDefaultDescription
dock"top" | "bottom""bottom"Panel position
heightnumber8Visible rows
maxEntriesnumber200Buffer size
showTimestampbooleantrueShow [HH:MM:SS] prefix
showLevelbooleantrueShow level label (LOG/WRN/ERR)
filterConsoleLevel[]all levelsWhich levels to capture

Entries are color-coded: gray (debug), white (log), blue (info), yellow (warn), red (error). Auto-scrolls to show the latest entry.

ConfirmInput

A Y/N confirmation prompt. Inspired by ink-ui.

ConfirmInput demo
import { ConfirmInput, Text } from "@visulima/tui";

<Text>Do you want to continue? </Text>
<ConfirmInput
    onConfirm={() => console.log("Confirmed!")}
    onCancel={() => console.log("Cancelled.")}
/>
PropTypeDefaultDescription
defaultChoice"confirm" | "cancel""confirm"Default action when Enter is pressed
isDisabledbooleanfalseIgnores all input when true
submitOnEnterbooleantrueWhether Enter submits the default choice
onConfirm() => void(required)Called when user presses Y
onCancel() => void(required)Called when user presses N

Shows Y/n when default is confirm, y/N when default is cancel. Press Y/y to confirm, N/n to cancel, Enter to submit the default.

MultiSelect

Multi-choice selection component with checkboxes and keyboard navigation. Inspired by ink-ui.

MultiSelect demo
import { MultiSelect } from "@visulima/tui";

const options = [
    { label: "TypeScript", value: "ts" },
    { label: "JavaScript", value: "js" },
    { label: "Python", value: "py" },
];

<MultiSelect options={options} onSubmit={(values) => console.log("Selected:", values)} />;
PropTypeDefaultDescription
optionsMultiSelectOption[](required)Available choices
defaultValuestring[][]Initially selected values
isDisabledbooleanfalseIgnores all input when true
isFocusedbooleantrueWhether the component captures input
limitnumberMax visible options; auto-limits to terminal height
accentColorstring"blue"Color for the focused indicator
defaultColorstringColor for unfocused, unselected labels
onChange(values: string[]) => voidCalled when selection changes (space)
onSubmit(values: string[]) => voidCalled when Enter is pressed

Keyboard: arrow keys or j/k to navigate, Space to toggle, a to select/deselect all, Enter to submit.

TextInput

Single-line text input with cursor navigation, autocomplete suggestions, and optional password masking. Inspired by ink-ui.

TextInput demo
import { TextInput } from "@visulima/tui";

<TextInput placeholder="Enter your name..." onSubmit={(value) => console.log("Name:", value)} />;
PropTypeDefaultDescription
defaultValuestring""Starting value
isDisabledbooleanfalseIgnores all input when true
maskbooleanfalseRenders asterisks instead of characters
placeholderstringGreyed-out text when input is empty
suggestionsstring[]Autocomplete candidates (case-sensitive prefix match)
onChange(value: string) => voidCalled on every keystroke
onSubmit(value: string) => voidCalled when Enter is pressed

Keyboard: left/right arrows for cursor, Home/End or Ctrl+A/E for start/end, Ctrl+U/K/W for line editing, Backspace/Delete, right arrow to accept suggestion, Enter to submit (accepts suggestion if pending).

Password mode

<TextInput mask onSubmit={(password) => authenticate(password)} />

Autocomplete

<TextInput suggestions={["react", "react-dom", "react-native"]} onSubmit={install} />

The first matching suggestion is shown dimmed after the cursor. Press right arrow or Enter to accept it.

Textarea

Multi-line text input with cursor navigation, selection, clipboard copy (OSC 52), undo/redo, paste support, and viewport scrolling.

import { Textarea } from "@visulima/tui";

<Textarea
    placeholder="Write something..."
    rows={8}
    showLineNumbers
    onChange={(value) => console.log(value)}
    onSubmit={(value) => console.log("Submitted:", value)}
/>;
PropTypeDefaultDescription
defaultValuestring""Starting content
placeholderstringGreyed-out text shown when empty
rowsnumber5Visible rows (viewport height)
maxRowsnumberAuto-grow up to this height before scrolling
isDisabledbooleanfalseIgnores all input and dims the text
isFocusedbooleantrueWhether the component responds to input
showLineNumbersbooleanfalseShow line numbers in the left gutter
tabSizenumber2Number of spaces inserted for Tab
onChange(value: string) => voidCalled on every edit
onSubmit(value: string) => voidCalled on Meta+Enter or Ctrl+Enter

Keyboard

KeyAction
Arrow keysMove cursor
Shift+ArrowsExtend selection
Home / Ctrl+AStart of line
End / Ctrl+EEnd of line
Ctrl+HomeStart of buffer
Ctrl+EndEnd of buffer
EnterInsert newline
Meta+Enter / Ctrl+EnterSubmit (onSubmit)
Backspace / DeleteDelete character
Ctrl+UKill to line start
Ctrl+KKill to line end
Ctrl+WKill word backward
TabInsert spaces (tabSize)
Ctrl+ZUndo
Ctrl+YRedo
Ctrl+Shift+ASelect all
Ctrl+C (with selection)Copy to clipboard (OSC 52)
EscapeClear selection
Paste (bracketed)Insert pasted text

Auto-growing height

<Textarea rows={3} maxRows={15} placeholder="Grows from 3 to 15 rows..." />

With line numbers

<Textarea showLineNumbers defaultValue={"function hello() {\n  return 'world';\n}"} rows={5} />

SelectInput

Interactive select input for choosing from a list of options. Ported from ink-select-input.

SelectInput demo
import { SelectInput } from "@visulima/tui";

const items = [
    { label: "TypeScript", value: "ts" },
    { label: "JavaScript", value: "js" },
    { label: "Python", value: "py" },
];

<SelectInput items={items} onSelect={(item) => console.log(item.value)} />;

Navigate with arrow keys or j/k, select with Enter, or jump to an item by pressing a number key (1–9).

Props

PropTypeDefaultDescription
itemsItem\<V>[][]Choices to display
isFocusedbooleantrueWhether the component captures keyboard input
initialIndexnumberInitially-selected item index; omit for no initial selection
indexnumberControlled selected index (see below)
limitnumberMax visible items; enables scroll windowing
accentColorstring"blue"Color for the selected item and indicator
defaultColorstringColor for unselected items
indicatorComponentFC\<IndicatorProps>SelectInputIndicatorCustom indicator component
itemComponentFC\<ItemProps>SelectInputItemCustom item component
onSelect(item: Item\<V>) => voidCalled on Enter or number key press
onHighlight(item: Item\<V>, index: number) => voidCalled when selection moves; second arg is the new index
resetOnItemsChangebooleantrueReset selection when item values change

Each item in the items array must have a label (display text) and a value (returned on select). An optional key provides a stable React key. An optional action function is called automatically when the item is selected.

Item actions

Instead of switching on value in the onSelect handler, you can define per-item behavior inline:

const items = [
    { label: "Build", value: "build", action: () => runBuild() },
    { label: "Test", value: "test", action: () => runTests() },
    { label: "Deploy", value: "deploy", action: () => deploy() },
];

<SelectInput items={items} initialIndex={0} />;

When an item is selected (Enter or number key), onSelect fires first (if provided), then item.action() is called. Both are optional — you can use either or both.

Separators

Add non-focusable separator lines between groups of items. Separators are skipped during keyboard navigation and cannot be selected:

const items = [
    { label: "Cut", value: "cut" },
    { label: "Copy", value: "copy" },
    { label: "Paste", value: "paste" },
    { isSeparator: true },
    { label: "Select All", value: "selectAll" },
];

<SelectInput items={items} initialIndex={0} />;

A separator renders as ─── by default. Pass a custom label to change the text:

{ isSeparator: true, label: "── Advanced ──" }

The SeparatorItem type is exported for use in typed item arrays:

import type { SelectInputEntry, SelectInputSeparator } from "@visulima/tui";

const items: SelectInputEntry<string>[] = [{ label: "Option A", value: "a" }, { isSeparator: true }, { label: "Option B", value: "b" }];

Note: the reset check compares item values only — changing labels or other properties while keeping the same values will not trigger a reset. Set resetOnItemsChange={false} to preserve the selection across all item changes.

No initial selection

When initialIndex is omitted, no item is highlighted until the user presses a navigation key. This is useful for rendering a list without a pre-selected value, similar to an empty radio group:

<SelectInput items={items} onHighlight={(item) => console.log("Browsing:", item.label)} />

The first down arrow or j press highlights the first item; the first up arrow or k press highlights the last. Enter is a no-op until an item is highlighted. Number keys (1–9) still work for direct selection.

To start with the first item already highlighted, pass initialIndex={0}.

Controlled mode

Pass index to control the selection externally. When index is set, initialIndex is ignored. Update index from your onHighlight callback to track the selection:

const [selectedIndex, setSelectedIndex] = useState(0);

<SelectInput
    items={items}
    index={selectedIndex}
    onHighlight={(item, idx) => setSelectedIndex(idx)}
    onSelect={(item) => console.log("Selected:", item.value)}
/>;

Scroll windowing

When no limit is specified and the list has more items than the terminal has rows, the component automatically caps the visible items to the terminal height. This prevents overflow without any manual measurement.

You can also set limit explicitly to control the window size:

<SelectInput items={longList} limit={5} />

The list wraps around when navigating past the first or last visible item.

Custom colors

Override the default blue highlight with accentColor and defaultColor:

<SelectInput items={items} accentColor="green" defaultColor="gray" />

These colors are passed through to the indicator and item components.

Focus dimming

When multiple SelectInput components are on screen, the unfocused ones automatically dim their selected item using dimColor. This makes it easy to tell which list has focus:

<Box>
    <SelectInput items={leftItems} initialIndex={0} isFocused={leftActive} />
    <SelectInput items={rightItems} initialIndex={0} isFocused={!leftActive} />
</Box>

The isFocused prop is passed through to both indicatorComponent and itemComponent, so custom components can implement their own unfocused styling.

Custom components

Replace the default indicator or item rendering:

import { Box, Text } from "@visulima/tui";

const CustomIndicator = ({ isSelected, isFocused, accentColor }) => (
    <Box marginRight={1}>
        <Text color={isSelected ? accentColor : undefined} dimColor={!isFocused}>
            {isSelected ? "→" : " "}
        </Text>
    </Box>
);

const CustomItem = ({ label, isSelected, isFocused, accentColor, defaultColor }) => (
    <Text bold={isSelected} color={isSelected ? accentColor : defaultColor} dimColor={isSelected && !isFocused}>
        {label}
    </Text>
);

<SelectInput items={items} indicatorComponent={CustomIndicator} itemComponent={CustomItem} />;

Custom components receive isSelected, isFocused, accentColor, and (for items) defaultColor plus the item's own label/value/key fields.

Tab / Tabs

Tabbed navigation component with keyboard support. Inspired by ink-tab.

import { Tab, Tabs } from "@visulima/tui";

<Tabs onChange={(name, tab) => console.log("Active:", name)}>
    <Tab name="files">Files</Tab>
    <Tab name="search">Search</Tab>
    <Tab name="settings">Settings</Tab>
</Tabs>;

Tabs Props

PropTypeDefaultDescription
childrenReactNode(required)<Tab> elements
onChange(name: string, tab: ReactElement) => void(required)Called when the active tab changes
defaultValuestringName of the initially active tab
flexDirection"row" | "column" | "row-reverse" | "column-reverse""row"Layout direction
isFocusedboolean | nullundefinedFocus state; false disables input, null = unmanaged
showIndexbooleantrueShow 1-based index numbers before tab names
widthnumber | stringContainer width (also separator width in column layouts)
keyMapKeyMapCustom keyboard bindings
colorsTabColorsActive tab color customization

Tab Props

PropTypeDefaultDescription
namestring(required)Unique identifier for this tab
childrenReactNode(required)Content rendered inside the tab

Keyboard

  • Left/Right arrows (or Up/Down in column layout): navigate between tabs
  • Tab/Shift+Tab: cycle through tabs (only when isFocused is unmanaged)
  • Meta/Cmd + 1–9: jump to a specific tab by index

Column layout

<Tabs flexDirection="column" onChange={handleChange}>
    <Tab name="a">Alpha</Tab>
    <Tab name="b">Beta</Tab>
</Tabs>

Custom colors

<Tabs colors={{ activeTab: { color: "cyan", backgroundColor: "white" } }} onChange={handleChange}>
    <Tab name="one">One</Tab>
    <Tab name="two">Two</Tab>
</Tabs>

Table

Render data as a formatted table in the terminal. Uses @visulima/tabular as the rendering engine, supporting multiple border styles, column configuration, cell formatting, and word wrap. Ported from ink-table.

import { Table } from "@visulima/tui";

const data = [
    { name: "Alice", age: 30, city: "NYC" },
    { name: "Bob", age: 25, city: "LA" },
];

<Table data={data} />
<Table data={data} borderStyle="rounded" padding={2} />

Props

PropTypeDefaultDescription
dataT[](required)Array of objects to display as rows
columns(string | ColumnConfig)[]all keysColumns to display and their order
paddingnumber1Cell padding (left and right) in characters
borderStylestring | BorderStyle"default"Border preset or custom object (see below)
maxWidthnumberMaximum table width; columns truncate/wrap to fit
showHeaderbooleantrueWhether to show the header row
formatCell(value, column, rowIndex) => stringCustom cell value formatter
formatHeader(column) => stringCustom header label formatter
skeletonstring""Replacement for null/undefined cells
wordWrapbooleanfalseEnable word wrap instead of truncation

Column Configuration

Columns accept either simple key strings or ColumnConfig objects for advanced control:

<Table data={data} columns={[{ key: "name", header: "Full Name", width: 20, align: "left" }, { key: "age", header: "Years", align: "right" }, "city"]} />
ColumnConfig fieldTypeDescription
keystring (required)Key from the data object
headerstringCustom header label (defaults to key)
widthnumberFixed column width in characters
align"left" | "center" | "right"Horizontal alignment

Border Styles

Available presets: "default", "rounded", "double", "minimal", "dots", "markdown", "ascii", "none", "block", "thick".

<Table data={data} borderStyle="rounded" />
<Table data={data} borderStyle="ascii" />
<Table data={data} borderStyle="none" />

You can also pass a custom BorderStyle object from @visulima/tabular for full control.

Custom Formatting

<Table
    data={data}
    formatCell={(value, column, rowIndex) => {
        if (column === "name") return String(value).toUpperCase();
        return String(value ?? "");
    }}
    formatHeader={(column) => column.toUpperCase()}
    skeleton="N/A"
/>

Note: Unlike the original ink-table, this component uses function-based formatters (formatCell, formatHeader) instead of custom React component props (header, cell, skeleton), because the table is rendered as a single string via @visulima/tabular.

Badge

Uppercase colored label badge for status indicators. Inspired by ink-ui.

Badge demo
import { Badge } from "@visulima/tui";

<Badge>new</Badge>
<Badge color="red">error</Badge>
<Badge color="green">success</Badge>
PropTypeDefaultDescription
childrenReactNode(required)Label content; strings are auto-uppercased
colorstring"magenta"Background color for the badge

StatusMessage

Status notification with a colored variant icon. Inspired by ink-ui.

StatusMessage demo
import { StatusMessage } from "@visulima/tui";

<StatusMessage variant="success">Build completed</StatusMessage>
<StatusMessage variant="error">Connection failed</StatusMessage>
<StatusMessage variant="warning">Disk space low</StatusMessage>
<StatusMessage variant="info">3 updates available</StatusMessage>
PropTypeDefaultDescription
childrenReactNode(required)Message text
variant"success" | "error" | "warning" | "info"(required)Determines icon and color

Icons: ✔ (success/green), ✖ (error/red), ⚠ (warning/yellow), ℹ (info/blue).

Alert

Bordered alert box with a variant-specific icon and optional title. Inspired by ink-ui.

Alert demo
import { Alert } from "@visulima/tui";

<Alert variant="info">Check the docs for details.</Alert>
<Alert variant="warning" title="Deprecation Notice">
    This API will be removed in v3.
</Alert>
PropTypeDefaultDescription
childrenReactNode(required)Alert message
variant"info" | "success" | "error" | "warning"(required)Border color and icon
titlestringBold heading above message

UnorderedList

Bullet list with nesting support and customizable markers. Inspired by ink-ui.

UnorderedList demo
import { UnorderedList } from "@visulima/tui";

<UnorderedList items={[{ label: "TypeScript" }, { label: "JavaScript" }, { label: "Python", children: [{ label: "Django" }, { label: "Flask" }] }]} />;
PropTypeDefaultDescription
itemsUnorderedListEntry[](required)List entries with label and optional children
markerstring | string[]["─", "◦", "▪"]Bullet symbol(s); arrays cycle by depth

Each entry has label: ReactNode and optional children: UnorderedListEntry[] for nesting.

OrderedList

Numbered list with hierarchical nesting and auto-padded markers. Inspired by ink-ui.

OrderedList demo
import { OrderedList } from "@visulima/tui";

<OrderedList
    items={[
        { label: "Install dependencies" },
        { label: "Configure", children: [{ label: "Create config file" }, { label: "Set environment variables" }] },
        { label: "Deploy" },
    ]}
/>;
PropTypeDefaultDescription
itemsOrderedListEntry[](required)List entries with label and optional children

Numbers are right-padded for alignment (e.g., 1., 2., 10.). Nested lists produce hierarchical markers (1., 1.1., 1.2.).

Paginator

A pagination component with a render-prop pattern and configurable page indicators. Handles keyboard navigation and slices items by page.

import { Paginator, Text, Box } from "@visulima/tui";

<Paginator items={allItems} pageSize={5}>
    {(pageItems, meta) => (
        <Box flexDirection="column">
            {pageItems.map((item, i) => (
                <Text key={i}>{item}</Text>
            ))}
        </Box>
    )}
</Paginator>
PropTypeDefaultDescription
itemsT[](required)Full item list to paginate
pageSizenumber(required)Items per page
children(pageItems: T[], meta: PageMeta) => ReactNode(required)Render prop for page content
pagenumberControlled current page (0-indexed)
defaultPagenumber0Initial page for uncontrolled mode
onChange(page: number) => voidCalled when page changes
isFocusedbooleantrueWhether keyboard navigation is active
style"dots" | "numeric" | "fraction""dots"Indicator style
indicatorColorstring"cyan"Color for the active indicator

PageMeta

The render-prop receives a PageMeta object as the second argument:

type PageMeta = {
    currentPage: number;
    totalPages: number;
    startIndex: number;
    endIndex: number;
    isFirstPage: boolean;
    isLastPage: boolean;
};

Indicator styles

  • dots: ● ○ ○ ○ — active dot highlighted
  • numeric: ‹ 1 / 4 › — arrows dim at boundaries
  • fraction: 1/4 — compact display

Keyboard

KeyAction
/ Page UpPrevious page
/ Page DownNext page
HomeFirst page
EndLast page

Controlled mode

const [page, setPage] = useState(0);
<Paginator items={data} pageSize={10} page={page} onChange={setPage}>
    {(items) => <MyList items={items} />}
</Paginator>

TreeView

Interactive tree view with keyboard navigation, expand/collapse, single/multi selection, async lazy-loading, and viewport virtualization. Ported from ink-tree-view.

import { TreeView } from "@visulima/tui";
import type { TreeNode } from "@visulima/tui";

const data: TreeNode[] = [
    {
        id: "src",
        label: "src",
        children: [
            { id: "src/index.ts", label: "index.ts" },
            {
                id: "src/components",
                label: "components",
                children: [{ id: "src/components/App.tsx", label: "App.tsx" }],
            },
        ],
    },
    { id: "package.json", label: "package.json" },
];

<TreeView data={data} defaultExpanded="all" />;

Keyboard controls

KeyAction
/ Move focus
Expand node, or move to first child if already expanded
Collapse node, or move to parent if already collapsed
EnterToggle expand (none mode) or select (single/multi mode)
SpaceToggle selection (multi mode) or expand (none/single)
HomeFocus first node
EndFocus last node

Props

PropTypeDefaultDescription
dataTreeNode\<T>[](required)Tree data. Must be referentially stable (use useMemo).
selectionMode"none" | "single" | "multiple""none""single" shows a tick, "multiple" shows checkboxes.
defaultExpandedReadonlySet\<string> | "all"IDs of nodes expanded on mount. Pass "all" to expand everything.
defaultSelectedReadonlySet\<string>IDs of nodes selected on mount.
visibleNodeCountnumberInfinityLimits visible rows (viewport virtualization). Scroll indicators appear automatically.
renderNode(props: TreeNodeRendererProps\<T>) => ReactNodeCustom node renderer. Receives { node, state }.
loadChildren(node: TreeNode\<T>) => Promise\<TreeNode\<T>[]>Async function called when expanding a node with isParent: true and no children.
onFocusChange(nodeId: string) => voidCalled when the focused node changes.
onExpandChange(expandedIds: ReadonlySet\<string>) => voidCalled when the expanded set changes.
onSelectChange(selectedIds: ReadonlySet\<string>) => voidCalled when the selection changes.
onLoadError(nodeId: string, error: Error) => voidCalled when loadChildren rejects.
isDisabledbooleanfalseIgnores keyboard input when true.
ariaLabelstring"Tree view"Accessible label for the tree container.

TreeNode\<T> shape

type TreeNode<T = Record<string, unknown>> = {
    id: string; // unique across the whole tree
    label: string; // display text
    data?: T; // arbitrary user data
    children?: TreeNode<T>[]; // child nodes
    isParent?: boolean; // show expand indicator even with no children (for async loading)
};

Custom rendering

Use renderNode for full control over each row:

<TreeView
    data={data}
    defaultExpanded="all"
    renderNode={({ node, state }) => (
        <Box gap={1}>
            <Text dimColor>{"  ".repeat(state.depth)}</Text>
            <Text color={state.isFocused ? "blue" : undefined}>
                {state.hasChildren ? (state.isExpanded ? "📂" : "📁") : "📄"} {node.label}
            </Text>
        </Box>
    )}
/>

Async lazy-loading

Mark nodes with isParent: true and provide a loadChildren callback:

const data: TreeNode[] = [{ id: "root", label: "Remote Files", isParent: true }];

<TreeView
    data={data}
    loadChildren={async (node) => {
        const response = await fetch(`/api/files/${node.id}`);
        return response.json();
    }}
    onLoadError={(nodeId, error) => console.error(`Failed to load ${nodeId}:`, error)}
/>;

A loading indicator () is shown while children are being fetched.

Viewport virtualization

Limit the visible rows for large trees:

<TreeView data={largeTree} defaultExpanded="all" visibleNodeCount={15} />

Scroll indicators (↑ N more above / ↓ N more below) appear automatically.

Headless usage

For full control, use the hooks directly instead of the \<TreeView> component:

import { useTreeViewState, useTreeView } from "@visulima/tui";

function CustomTree({ data }) {
    const state = useTreeViewState({ data, selectionMode: "single" });
    useTreeView({ state, selectionMode: "single" });

    return (
        <Box flexDirection="column">
            {state.viewportNodes.map(({ node, state: nodeState }) => (
                <Text key={node.id} color={nodeState.isFocused ? "blue" : undefined}>
                    {"  ".repeat(nodeState.depth)}
                    {nodeState.isExpanded ? "▾" : nodeState.hasChildren ? "▸" : " "} {node.label}
                </Text>
            ))}
        </Box>
    );
}

Theming

The default theme can be imported and inspected:

import { treeViewTheme } from "@visulima/tui";

// treeViewTheme.styles.label({ isFocused: true, isSelected: false })
// => { color: "blue" }

For full visual customization, prefer renderNode over theme overrides.

Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues