▚Textareacustom
Multiline text input with cursor
$ termino textarea — focus and type
type something…
cursor 0:0
##API
A small editor state machine: `lines: string[]` plus a `{ row, col }` cursor. Character keys insert via `key.sequence`, named keys (backspace, return, arrows) edit. Renders at most `rows` lines — no scrollbar yet.
properties
| prop | type | default | description |
|---|---|---|---|
| defaultValue | string | "" | Initial text |
| onChange | (value: string) => void | - | Fired with the full text on every edit |
| rows | number | 3 | Max visible lines |
| placeholder | string | "" | Shown when empty and unfocused |
| focused | boolean | true | Gate for keyboard handling |
| cursorFg / cursorBg | string | "#16161e" / "#7dcfff" | Cursor cell colors |
##Keymap
charsInsert at cursor (key.sequence)
returnSplit line
backspace / deleteDelete left / right
↑ ↓ ← →Move cursor
home / endLine start / end
##Notes
- ▸The cursor renders as an inverted cell mid-line, or a filled block at end-of-line.
- ▸Controlled `value` is not (yet) supported — pair with onChange if you need it.
##Source
Real implementation — this is the code that runs in your terminal.
1/* eslint-disable react/no-array-index-key -- These renderers draw a fixed2 terminal grid: a child's identity *is* its row and column, and the grid3 never reorders, so the index is the stable key rather than a stand-in4 for one. */56import { createElement as h, useState } from "react";7import { useKeyboard } from "@opentui/react";8import type { KeyEvent } from "@opentui/core";910export interface TextareaProps {11 defaultValue?: string;12 onChange?: (value: string) => void;13 rows?: number;14 placeholder?: string;15 focused?: boolean;16 fg?: string;17 placeholderFg?: string;18 cursorFg?: string;19 cursorBg?: string;20}2122/**23 * The character a key typed, or null if it typed nothing. Printable keys24 * arrive with `key.name` set to the character itself (e.g. "q"), so the test25 * is on the sequence rather than on `key.name` being undefined.26 */27function printableChar(key: KeyEvent): string | null {28 const seq = key.sequence;29 if (!seq || seq.length !== 1 || seq < " " || key.ctrl || key.meta) return null;30 return seq;31}3233export function Textarea({34 defaultValue = "",35 onChange,36 rows = 3,37 placeholder = "",38 focused = true,39 fg = "#c0caf5",40 placeholderFg = "#565f89",41 cursorFg = "#16161e",42 cursorBg = "#7dcfff",43}: Readonly<TextareaProps>) {44 const [lines, setLines] = useState<string[]>(() =>45 defaultValue.split("\n").slice(0, rows),46 );47 const [pos, setPos] = useState({ row: 0, col: 0 });4849 const clampCol = (row: number, col: number) => {50 const line = lines[row] ?? "";51 return Math.max(0, Math.min(col, line.length));52 };5354 const emit = (next: string[]) => {55 setLines(next);56 onChange?.(next.join("\n"));57 };5859 const insert = (text: string) => {60 const next = lines.slice();61 const line = next[pos.row] ?? "";62 next[pos.row] = line.slice(0, pos.col) + text + line.slice(pos.col);63 setPos({ row: pos.row, col: pos.col + text.length });64 emit(next);65 };6667 /** Splits the current line at the caret. Once the text fills the visible68 * rows the topmost line scrolls off rather than the box growing. */69 const breakLine = () => {70 const next = lines.slice();71 next.splice(pos.row + 1, 0, (next[pos.row] ?? "").slice(pos.col));72 next[pos.row] = (next[pos.row] ?? "").slice(0, pos.col);73 if (next.length >= rows && rows > 0) {74 next.shift();75 setPos({ row: rows - 1, col: 0 });76 } else {77 setPos({ row: Math.min(pos.row + 1, next.length - 1), col: 0 });78 }79 emit(next);80 };8182 /** Deletes the character before the caret, joining onto the line above when83 * the caret is already at the start of a line. */84 const deleteBackward = () => {85 const next = lines.slice();86 if (pos.col > 0) {87 const line = next[pos.row] ?? "";88 next[pos.row] = line.slice(0, pos.col - 1) + line.slice(pos.col);89 setPos({ row: pos.row, col: pos.col - 1 });90 emit(next);91 return;92 }93 if (pos.row === 0) return;94 const prev = next[pos.row - 1] ?? "";95 next[pos.row - 1] = prev + (next[pos.row] ?? "");96 next.splice(pos.row, 1);97 setPos({ row: pos.row - 1, col: prev.length });98 emit(next);99 };100101 /** Deletes the character under the caret, pulling up the line below when102 * the caret is at the end of a line. */103 const deleteForward = () => {104 const next = lines.slice();105 const line = next[pos.row] ?? "";106 if (pos.col < line.length) {107 next[pos.row] = line.slice(0, pos.col) + line.slice(pos.col + 1);108 emit(next);109 return;110 }111 if (pos.row >= next.length - 1) return;112 next[pos.row] = line + (next[pos.row + 1] ?? "");113 next.splice(pos.row + 1, 1);114 emit(next);115 };116117 /** Caret movement. Returns true when the key was a movement key. */118 const moveCaret = (name: string | undefined): boolean => {119 switch (name) {120 case "left":121 if (pos.col > 0) setPos((p) => ({ row: p.row, col: p.col - 1 }));122 else if (pos.row > 0) {123 setPos({ row: pos.row - 1, col: (lines[pos.row - 1] ?? "").length });124 }125 return true;126 case "right":127 if (pos.col < (lines[pos.row] ?? "").length) {128 setPos((p) => ({ row: p.row, col: p.col + 1 }));129 } else if (pos.row < lines.length - 1) setPos({ row: pos.row + 1, col: 0 });130 return true;131 case "up":132 setPos((p) => ({ row: Math.max(0, p.row - 1), col: clampCol(p.row - 1, p.col) }));133 return true;134 case "down":135 setPos((p) => ({136 row: Math.min(lines.length - 1, p.row + 1),137 col: clampCol(p.row + 1, p.col),138 }));139 return true;140 case "home":141 setPos((p) => ({ row: p.row, col: 0 }));142 return true;143 case "end":144 setPos((p) => ({ row: p.row, col: (lines[p.row] ?? "").length }));145 return true;146 default:147 return false;148 }149 };150151 const handleKey = (key: KeyEvent) => {152 if (moveCaret(key.name)) return;153 if (key.name === "return") return breakLine();154 if (key.name === "backspace") return deleteBackward();155 if (key.name === "delete") return deleteForward();156 if (key.name === "space") return insert(" ");157 const typed = printableChar(key);158 if (typed) insert(typed);159 };160161 useKeyboard((key) => {162 if (!focused) return;163 handleKey(key);164 });165166 const visible = lines.slice(0, rows);167 const empty = visible.every((l) => l === "");168169 return h(170 "box",171 { flexDirection: "column" },172 visible.map((line, ri) => {173 const display = empty && ri === 0 && !focused ? placeholder : line;174 const isPlaceholder = empty && ri === 0 && !focused;175 const col = ri === pos.row ? pos.col : display.length;176 const clamped = Math.min(col, display.length);177 const before = display.slice(0, clamped);178 const at = display[clamped] ?? "";179 const after = display.slice(clamped + 1);180 const showCursor = focused && ri === pos.row;181182 return h(183 "box",184 { key: ri, flexDirection: "row" },185 h(186 "text",187 { fg: isPlaceholder ? placeholderFg : fg },188 before,189 ),190 showCursor191 ? h(192 "text",193 {194 fg: at ? cursorBg : cursorFg,195 bg: at ? cursorBg : undefined,196 },197 at || "█",198 )199 : h(200 "text",201 { fg: isPlaceholder ? placeholderFg : fg },202 at,203 ),204 h(205 "text",206 { fg: isPlaceholder ? placeholderFg : fg },207 after,208 ),209 );210 }),211 );212}213