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.
Contents
- Features
- Install
- Usage
- 1. Basic
- 2. Controlled mode with cursor labels
- 3. Line numbers and active-line highlight
- 4. Inline syntax highlighting
- 5. Multi-field form with focus chaining
- 6. Slash-command picker (arrow + tab handoff)
- 7. Code-editor preset
- Props
- Imperative API (ref)
- Keybindings
- Keybinding Toggles
- Caveats & limitations
- Development
- License
Features
- 🎨 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
Install react-ink-textarea via your JS package manager of choice.
npm install react-ink-textarea
# or
pnpm add react-ink-textareaUsage
1. Basic
Uncontrolled 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 />);
2. Controlled mode with cursor labels
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> ); };
3. Line numbers and active-line highlight
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> ) } />;
4. Inline syntax highlighting
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 }, }} /> ); };
5. Multi-field form with focus chaining
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> ); };
6. Slash-command picker (arrow + tab handoff)
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> ); };
7. Code-editor preset
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> ); };
Props
| 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. |
Imperative API (ref)
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. |
Keybindings
| 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.
Keybinding Toggles
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.
Caveats & limitations
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.
Development
# Install dependencies pnpm install # Build pnpm run build # Watch mode pnpm run dev # Run tests pnpm test # Format code pnpm run format
License
MIT





















