A multiline textarea component for Ink
Build rich CLI forms with a full-featured textarea that supports multi-line editing, cursor navigation, undo, and customizable line prefixes.
- Features
- Install
- Usage
- Props
- Imperative API (ref)
- Keybindings
- Caveats & limitations
- Development
- License
- 🎨 Polished feel — blinking cursor that pauses while typing, active-line highlight, multi-line placeholder, optional whitespace glyphs.
- 🪪 Custom gutter via
linePrefixrender-prop, plus a drop-in<LineNumberPrefix />. - 🌈 Regex (or function) labels with per-label styles; cursor reports the label under it.
- ⌨️ Readline keybindings, configurable per chord.
Tabis a callback. Grouped undo and bracketed paste. - 🌐 Unicode-correct: grapheme cursor, visual-width wrapping, real tab expansion, CRLF normalized.
- 📐 Built-in viewport virtualization; auto-scroll; resize-aware.
- 🧭 Boundary callbacks (
onFirstLineUp,onLastLineDown,onFirstCharacterLeft,onLastCharacterRight) for parent-owned focus chaining. - ⚛️ Controlled, uncontrolled, or mixed. Imperative
ref.insert(text)for autocomplete pickers. - 🧷 Strict TypeScript, tree-shakable.
- 🧪 Works with
ink-testing-library; 250+ tests in-repo.
Install react-ink-textarea via your JS package manager of choice.
npm install react-ink-textarea
# or
pnpm add react-ink-textareaUncontrolled mode. Submit on Enter, freeze on submit.
import { render } from "ink";
import { useState } from "react";
import { TextArea } from "react-ink-textarea";
const App = () => {
const [submitted, setSubmitted] = useState("");
const [focus, setFocus] = useState(true);
return (
<TextArea
focus={focus}
placeholder="Type your message here..."
onSubmit={(value) => {
setSubmitted(value);
setFocus(false);
}}
/>
);
};
render(<App />);Own value and cursorPosition externally. onCursorChange reports the label and chunk index under the cursor — drive a status line, hover-card, or autocomplete from it.
import { Box, Text } from "ink";
import { useState } from "react";
import { TextArea, type TLabels } from "react-ink-textarea";
const labels: TLabels = [{ pattern: /#\w+/g, label: "tag" }];
const styles = { tag: { color: "magenta" } };
const Editor = () => {
const [value, setValue] = useState("hello #world");
const [cursor, setCursor] = useState<[number, number]>([0, 0]);
const [info, setInfo] = useState("text");
return (
<Box flexDirection="column">
<TextArea
focus
value={value}
cursorPosition={cursor}
labels={labels}
styles={styles}
onChange={setValue}
// (pos, labelType, chunkIndex) — labelType is "text" outside any match
onCursorChange={(pos, type, idx) => {
setCursor(pos);
setInfo(type === "text" ? "text" : `${type}#${idx}`);
}}
onSubmit={() => {}}
/>
<Text dimColor>under cursor: {info}</Text>
</Box>
);
};Drop-in: pass the bundled LineNumberPrefix straight to linePrefix.
import { TextArea, LineNumberPrefix } from "react-ink-textarea";
<TextArea
focus
highlightActiveLine
activeLineColor="#222"
initialLineCount={6}
onSubmit={() => {}}
linePrefix={LineNumberPrefix}
/>;Custom: linePrefix is a render prop. Handle isContinuationLine and isVirtualLine to draw clean gutters when wrapping or padding. Compose with the bundled LineNumber for the digits.
import { Text } from "ink";
import { TextArea, LineNumber } from "react-ink-textarea";
<TextArea
focus
highlightActiveLine
initialLineCount={6}
onSubmit={() => {}}
linePrefix={({
lineNumber,
totalLines,
isActiveLine,
isContinuationLine,
isVirtualLine,
}) =>
isContinuationLine ? (
<Text color="gray"> ↳ </Text>
) : (
<Text>
<LineNumber
lineNumber={lineNumber}
totalLines={totalLines}
isActive={isActiveLine}
padChar=" "
activeColor="cyan"
/>
<Text color={isVirtualLine ? "gray" : "white"}> │ </Text>
</Text>
)
}
/>;labels mixes regex rules with function-form rules for allowlists. First rule wins on overlap. styles.text and styles.invisibleCharacter are reserved keys; everything else maps to a label name.
import { useMemo } from "react";
import { TextArea, type TLabels } from "react-ink-textarea";
const KNOWN_USERS = new Set(["alice", "bob", "carol"]);
const Editor = () => {
const labels = useMemo<TLabels>(
() => [
{ pattern: /https?:\/\/\S+/g, label: "url" },
{ pattern: /#\w+/g, label: "tag" },
// function form: leave unknown @handles unstyled by returning undefined
{
pattern: /@(\w+)/g,
label: (m) => (KNOWN_USERS.has(m[1]) ? "mention" : undefined),
},
],
[],
);
return (
<TextArea
focus
onSubmit={() => {}}
showInvisibles={{ space: false, tab: true, newline: false }}
labels={labels}
styles={{
text: { color: "white" },
invisibleCharacter: { color: "gray", dim: true },
url: { color: "blue", underline: true },
tag: { color: "magenta" },
mention: { color: "green", bold: true },
}}
/>
);
};Boundary callbacks let you escape the textarea cleanly: ↑ on the first row jumps to the field above, ↓ past the last line jumps below, and ←/→ at the absolute ends do the same horizontally.
import { Box, useFocusManager, useFocus } from "ink";
import { useState } from "react";
import { TextArea } from "react-ink-textarea";
import TextInput from "ink-text-input";
const Form = () => {
const { focusNext, focusPrevious } = useFocusManager();
const subject = useFocus({ id: "subject" });
const body = useFocus({ id: "body" });
const tags = useFocus({ id: "tags" });
const [s, setS] = useState("");
const [b, setB] = useState("");
const [t, setT] = useState("");
return (
<Box flexDirection="column" gap={1}>
<TextInput value={s} onChange={setS} focus={subject.isFocused} />
<TextArea
focus={body.isFocused}
value={b}
onChange={setB}
onSubmit={() => {}}
onFirstLineUp={focusPrevious}
onLastLineDown={focusNext}
onFirstCharacterLeft={focusPrevious}
onLastCharacterRight={focusNext}
/>
<TextInput value={t} onChange={setT} focus={tags.isFocused} />
</Box>
);
};When a menu opens, suspend cursor navigation with disableArrowNavigation and disable Enter so the picker — not the textarea — handles them. Re-enable on close.
import { Box, Text } from "ink";
import { useState } from "react";
import { TextArea } from "react-ink-textarea";
const COMMANDS = ["/help", "/quit", "/train"];
const Composer = () => {
const [value, setValue] = useState("");
const [sel, setSel] = useState(0);
const open = value.startsWith("/");
return (
<Box flexDirection="column">
<TextArea
focus
value={value}
onChange={setValue}
onSubmit={(v) => {
if (!open) console.log(v);
}}
// While the picker is open, give it the arrows + Enter; typing still flows.
disableArrowNavigation={open}
keybindings={open ? { Enter: false } : undefined}
onTab={(shift) =>
setSel((i) => (i + (shift ? -1 : 1) + COMMANDS.length) % COMMANDS.length)
}
/>
{open && (
<Box flexDirection="column">
{COMMANDS.map((c, i) => (
<Text key={c} color={i === sel ? "cyan" : undefined}>
{i === sel ? "▸ " : " "}
{c}
</Text>
))}
</Box>
)}
</Box>
);
};Override the viewport explicitly, expand tabs, lock down ergonomic chords, and surface the measured content width to a status bar.
import { Box, Text } from "ink";
import { useState } from "react";
import { TextArea } from "react-ink-textarea";
const CodeEditor = () => {
const [width, setWidth] = useState(0);
return (
<Box flexDirection="column">
<TextArea
focus
onSubmit={() => {}}
viewportLines={20}
tabWidth={2}
autoNewLineLimit={1}
showInvisibles
keybindings={{
// Single-undo-stack ergonomics: defer undo to the host (e.g. file-level)
"Ctrl+Z": false,
// Keep Shift+Enter free for the parent's "submit-with-newline" gesture
"Shift+Enter": false,
}}
onDimensions={setWidth}
/>
<Text dimColor>width: {width} cols</Text>
</Box>
);
};| Prop | Type | Description |
|---|---|---|
focus |
boolean |
Whether the textarea is focused and receiving keyboard input. |
onSubmit |
(value: string) => void |
Called when the user presses Enter. Receives the full text. |
placeholder |
string |
Placeholder text shown when the textarea is empty. |
linePrefix |
ReactNode | (props: TLinePrefixProps) => ReactNode |
Optional prefix rendered before each line. The function form receives { lineNumber, totalLines, isActiveLine, isVirtualLine, isContinuationLine, continuationIndex }. Use for line numbers, gutters, borders, etc. |
highlightActiveLine |
boolean |
When true, the active line is highlighted with a subtle background color. Defaults to false. |
activeLineColor |
string |
Background color for the active line highlight. Defaults to no color. |
cursorInterval |
number |
Cursor blink interval in milliseconds. Defaults to 500. |
typingPause |
number |
Milliseconds to wait after typing before resuming cursor blink. Defaults to 450. |
maxUndo |
number |
Maximum number of undo steps to retain. Defaults to 128. |
undoGroupDelay |
number |
Milliseconds to group consecutive edits into a single undo step. Defaults to 750. |
autoNewLineLimit |
number |
Maximum number of empty lines allowed after the last line with content. Only applies to Down arrow navigation. Defaults to 3. |
disableArrowNavigation |
boolean |
When true, disables cursor movement via arrow keys (and word/line jumps). Useful for implementing suggestion pickers. Defaults to false. |
keybindings |
Partial<Record<TKeybinding, boolean>> |
Per-chord enable/disable map. Merged over defaults (all true). Set a chord to false to swallow it. disableArrowNavigation: true additionally forces all nav chords off. See Keybinding Toggles below. |
initialLineCount |
number |
Number of lines to display initially. The textarea will maintain at least this many lines. Defaults to 2. |
viewportLines |
number |
Maximum number of visual rows rendered at once. The textarea virtualizes rendering and auto-scrolls to keep the cursor visible. Defaults to floor(stdout.rows * 0.5) so blink re-renders don't scroll-jank tall buffers when the frame exceeds the terminal viewport. Pass an explicit number to override; Infinity renders every row. |
tabWidth |
number |
Visual width of \t characters in cells. Tabs render as tabWidth spaces (or → + spaces with showInvisibles.tab). The stored value keeps \t. Defaults to 4. |
value |
string |
Controlled mode: The current value of the textarea. When provided, component operates in controlled mode. |
cursorPosition |
[line: number, column: number] |
Controlled mode: The current cursor position as a [line, column] tuple. Use with value for full control. |
onChange |
(value: string) => void |
Controlled mode: Called when the value changes. |
onCursorChange |
(position: [line, column], type: string, chunkIndex: number) => void |
Controlled mode: Called when the cursor moves. type is the label at the cursor ("text" if no label matches); chunkIndex is the zero-based index of the labeled segment the cursor is in. |
onFirstLineUp |
() => void |
Called when Up arrow is pressed on the first visual row. Useful for moving focus out of the textarea. |
onLastLineDown |
() => void |
Called when Down arrow is pressed on the last line and trailing-empty-line limit is reached. Useful for moving focus out. |
onFirstCharacterLeft |
() => void |
Called when Left arrow is pressed at the very start of the value (cursor === 0). Useful for moving focus to a previous field. |
onLastCharacterRight |
() => void |
Called when Right arrow is pressed at the very end of the value (cursor === value.length). Useful for moving focus to a next field. |
onTab |
(shift: boolean) => void |
Called when Tab is pressed. shift is true for Shift+Tab. Without this prop, Tab is silently swallowed (no value mutation). |
onDimensions |
(width: number) => void |
Called with the measured content width whenever it changes. |
showInvisibles |
boolean | { space?: boolean; tab?: boolean; newline?: boolean } |
Render whitespace glyphs (· for space, → for tab, ↵ for newline). Defaults to false. |
styles |
{ text?, invisibleCharacter?, [labelName]? } of TStyleProps |
Style overrides for the default text run, invisible glyphs, and any user-defined labels. color and bgColor accept any value Ink's <Text> accepts — see the Ink color reference. |
labels |
readonly { pattern: RegExp; label: string | ((match: RegExpMatchArray) => string | undefined) }[] |
Array of label rules. Each rule's pattern is matched against the value; matches receive the rule's label. Use a function form to allowlist matches — return undefined to leave a match unlabeled. First rule wins on overlap. |
Pass a ref of type TextAreaHandle to insert text programmatically — typically from an autocomplete picker. Insertion happens at the current cursor and advances it. Works in both controlled and uncontrolled modes.
import { useRef } from "react";
import { TextArea, type TextAreaHandle } from "react-ink-textarea";
const Composer = () => {
const ref = useRef<TextAreaHandle>(null);
return (
<TextArea
ref={ref}
focus
onSubmit={() => {}}
onTab={() => ref.current?.insert("/help ")}
/>
);
};| Method | Description |
|---|---|
insert(text: string) |
Insert text at the current cursor and advance it past the inserted text. Empty string is a no-op. |
| Key | Action |
|---|---|
Ctrl+J |
Insert newline |
Ctrl+Enter |
Insert newline |
Shift+Enter |
Insert newline |
Alt+Enter |
Insert newline (Option+Enter) |
Enter |
Submit |
↑ / ↓ |
Move cursor between lines |
← / → |
Move cursor left / right |
Opt+← |
Jump to previous word |
Opt+→ |
Jump to next word |
Ctrl+A |
Start of current line |
Ctrl+E |
End of current line |
Ctrl+W |
Delete word before cursor |
Ctrl+U |
Delete to start of line. At column 0, joins with previous line (matches Cmd+Backspace mapping in iTerm2/ghostty). |
Ctrl+K |
Delete to end of line |
Backspace |
Delete character before cursor |
Delete |
Delete character before cursor (same as Backspace) |
Opt+Backspace |
Delete word before cursor |
Ctrl+Z |
Undo (up to 128 steps) |
On macOS,
Altchords are pressed via the Option (⌥) key.
Pass a keybindings map to disable individual chords. Keys are the chord strings themselves; values are true (enabled) or false (disabled). Anything you don't list defaults to enabled.
<TextArea
focus
onSubmit={onSubmit}
keybindings={{
"Ctrl+Z": false, // disable undo
"Shift+Enter": false, // disable Shift+Enter newline (other newline chords still work)
"Alt+B": false, // disable previous-word jump
}}
/>The full chord catalog (every key is a TKeybinding):
| Chord | Action |
|---|---|
Enter |
Submit |
Ctrl+J |
Insert newline |
Ctrl+Enter |
Insert newline |
Shift+Enter |
Insert newline |
Alt+Enter |
Insert newline |
Up |
Cursor up |
Down |
Cursor down |
Left |
Cursor left |
Right |
Cursor right |
Alt+B |
Previous word |
Alt+F |
Next word |
Ctrl+A |
Start of line |
Ctrl+E |
End of line |
Ctrl+W |
Delete word before cursor |
Ctrl+U |
Delete to start of line |
Ctrl+K |
Delete to end of line |
Backspace |
Delete grapheme before cursor |
Delete |
Delete grapheme before cursor |
Alt+Backspace |
Delete word before cursor |
Ctrl+Z |
Undo |
disableArrowNavigation: true additionally forces all nav chords (Up, Down, Left, Right, Alt+B, Alt+F, Ctrl+A, Ctrl+E) off regardless of the map.
Things to know before shipping. Most are intrinsic to running a rich editor inside a terminal.
Environment
- Terminal-only. Built on Ink — won't render in a browser. Requires Node 18+ for
Intl.Segmenter. Peer deps:ink ^7,react ^18 || ^19. - Monospace assumption. Layout assumes every cell is one column wide and CJK / wide chars take two. Variable-width fonts in some emulators (rare) will break alignment.
- Visual width via
string-width. Emoji + ZWJ widths follow Unicode tables; some terminals (notably older macOS Terminal, tmux withoutset -g escape-time 0) render the same glyph at a different cell count and produce off-by-one cursor placement.
Keybinding edge cases
- Modifier+Enter detection is terminal-dependent.
Ctrl+Enter,Shift+Enter,Alt+Enterrely onmodifyOtherKeys/ CSI-u sequences. macOS Terminal.app and Windows console don't emit them by default — use iTerm2, WezTerm, Kitty, or Alacritty, or fall back toCtrl+Jfor newline. Alton macOS. Option key inserts special chars (Opt+B→∫) unless the terminal is set to "Use Option as Meta" (iTerm2: Profiles → Keys; Terminal.app: Profiles → Keyboard → Use Option as Meta key).Tabis silently swallowed withoutonTab. No newline, no insert, no error. Provide the handler if you want any Tab behavior.disableArrowNavigationis not read-only. Typing still mutates the buffer. Usefocus={false}for true read-only.- Multiple focused TextAreas race. Ink's
useInputdelivers keys to every active hook; two textareas withfocus={true}will both mutate. Gate viafocusper instance.
Paste & clipboard
- Bracketed paste required for one-step paste. Terminals that don't emit
\x1b[200~(Windows cmd, very old emulators) deliver pastes as individual keystrokes — slow, and each char becomes its own undo step (effectively breakingCtrl+Zfor the paste). - No programmatic clipboard. No copy API; no setter for paste content. Anything the terminal doesn't deliver, the textarea can't see.
- No mouse. Click-to-position, drag-select, scroll wheel — none of it. Cursor moves only via keys.
Selection & search
- No selection. Point cursor only. No range, no shift+arrow extension, no copy of a range.
- No find/replace. Build it on top via controlled mode if you need it.
Undo
- Time-grouped, not semantic. Edits within
undoGroupDelay(default 2.5 s) collapse into one step. On a slow machine the boundary may land mid-word. - Bounded by
maxUndo(default 128). Older history is dropped silently. - No redo.
Ctrl+Y/Ctrl+Shift+Zare not bound. Add yourself if needed.
Labels & styles
- Regex runs on every value change. O(value × rules). Heavy patterns on 100k+ char buffers will lag — debounce externally or scope rules.
- Flat ranges, no nesting. A label can't contain another label. First rule wins on overlap; later rules silently lose.
- Function-form
undefined≠ "fall through". Returningundefinedleaves the match unlabeled (renders withtextstyle). It does not let the next rule try. color/bgColorstrings are passed straight to Ink's<Text>. Invalid values fail silently — the terminal renders default. See the Ink color reference.
Performance
- Cursor blink causes re-renders every
cursorIntervalms (default 500). Tall frames + slow terminals can flicker. BumpcursorIntervalor set it high to effectively disable. viewportLinesdefaults tofloor(stdout.rows * 0.5). Without virtualization, frames taller than the terminal trigger scroll-jank on every blink. The default trades render area for stability — override explicitly if you have height to spare.- Visual-row recompute is O(value) per keystroke. Acceptable for chat/comment buffers; pathological for full-file editing of large source files.
- Resize listener is global. Every TextArea instance subscribes to
stdout.resize. Dozens of simultaneous instances will fan out resize events.
Data model
- CRLF/CR normalized to LF on paste and controlled values.
onChangealways reports LF. If your storage layer needs CRLF, convert on save. value.length≠ visual length. Tabs count as 1 char regardless oftabWidth; emoji count as their UTF-16 code-unit length, not 1 grapheme. UseIntl.Segmenterif you need grapheme counts externally.- Out-of-bounds
cursorPositionis clamped silently. No throw, no warning —onCursorChangereports the clamped value. - Boundary callbacks fire on exact bounds only.
onFirstLineUponly fires when the cursor is on row 0 and ↑ is pressed; not "the cursor moved past the top." Same for the other three. onSubmitis sync. No promise return contract — coordinate async validation in your parent component.
Virtualization gotchas
- Rows outside
viewportLinesare not rendered. Any consumer-side measurement on hidden rows (useBoxMetrics, refs inlinePrefix) won't fire until the row scrolls in. - Wrapping happens at the measured content width. Constrain via a parent
<Box width={...}>to wrap at a fixed column; otherwise wraps atstdout.columns.
# Install dependencies
pnpm install
# Build
pnpm run build
# Watch mode
pnpm run dev
# Run tests
pnpm test
# Format code
pnpm run formatMIT