Textarea
Multi-line text input on the Base UI Field contract — shell variants, validation, header/footer slots, counter modes, auto-resize, and LTR/RTL support.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Textarea } from "@tessinaui/ui";{/* Basic */}
<Textarea label="Description" placeholder="Write something…" />
{/* With character counter */}
<Textarea
label="Bio"
maxLength={160}
showCharacterCounter
placeholder="Tell us about yourself…"
/>
{/* Validation on the Base UI Field contract */}
<Textarea
label="Review"
validate={(v) => (typeof v === "string" && v.length < 20 ? "Too short" : null)}
validationMode="onChange"
/>
{/* Composer shell */}
<Textarea
variant="plain"
label="Add a comment"
visuallyHiddenLabel
autoResize
maxRows={6}
submitOn="mod-enter"
footer={<Button size="xs">Send</Button>}
/>Examples
Default
A basic textarea with a label and placeholder.
Variants
Four shells: the default outline box, a filled well, a dense-form underline rule, and the chrome-free plain composer shell. plain keeps its focus ring, and filled/plain regain a visible boundary whenever the intent is not none.
Sizes
Five sizes from xs to xl. Type follows the input family's shared ladder — 12/14/14/16/16 on desktop with a 16px floor on phones, so iOS never zooms on focus.
Intents
Validation states — error, warning, success, and info — recolor the border, focus ring, and supporting text.
Validation
errorMessage replaces the supporting text and marks the field invalid; validate runs on the Base UI Field contract (onSubmit, onBlur, or debounced onChange).
Character counter
Grapheme-aware counting (an emoji counts once) in three formats — ratio, remaining, count — placed below or inside the box. A softLimit recommends a length without blocking typing; a hard maxLength over-run also sets aria-invalid. Announcements fire in bands, never per keystroke.
Auto-resize
With autoResize, the height grows as you type — capped here at six rows before scrolling. A ResizeObserver keeps the measurement honest across container-width changes and late font loads.
Resize
The drag grip resizes the whole field group — helper rows follow, and the width floors at the widest helper row. maxRows now caps the height even without autoResize.
Label placement
Place the label top, inside the container, or at the start (logical — right side in RTL). optional renders the muted counterpart to required's asterisk.
Composer
The plain shell plus header/footer slots, visuallyHiddenLabel, clearable, and submitOn="mod-enter". Send/stop behaviour, attachments and uploads belong to prompt-input — Textarea supplies the shell.
States
readOnly (focusable, selectable, not editable), disabled, isLoading (edits blocked, aria-busy), and monospace.
On color
Inside a coloured Surface, the well, ink and helper colours derive from the surface ink contract — the same tone model as Field and Button. Each variant keeps its own anatomy on the plate: filled has no resting border, underline focuses with an ink band instead of a ring, and plain stays bare until an intent gives it a boundary.
AI assist
A footer action that fills the field, isLoading while in flight, and a value the user can edit or clear afterwards — the human-in-the-loop recipe.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "outline" | "filled" | "underline" | "plain" | "outline" | Shell: bordered box, tinted well, bottom rule, or chrome-free composer |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Density; type reads the input family's shared ladder with a 16px phone floor |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Border, focus-ring and helper colour |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Token radii (0/6/12/16/24px); full caps at 24px so a tall box never domes |
label | ReactNode | — | Label text; names the control via the Base UI Field wiring |
labelPlacement | "top" | "inside" | "start" | "top" | Logical label position (start flips in RTL) |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Width of the start-label column, so sibling fields in a form share one control column — sm 6rem · md 8rem · lg 11rem · xl 14rem. Labels wrap, never truncate. Only applies with labelPlacement="start", from sm up |
labelPosition | "outside-top" | "inside" | "outside-left" | — | Deprecated — alias for labelPlacement |
required | boolean | — | Asterisk + aria-required |
optional | boolean | — | Muted "(optional)" marker; mutually exclusive with required |
visuallyHiddenLabel | boolean | — | Keep the label for AT only (composer shells) |
infoText | string | — | Hint at the label row's end, joined into aria-describedby |
supportingText | ReactNode | — | Helper below the field; replaced by the error row while invalid |
errorMessage | ReactNode | — | Error row; sets aria-invalid, implies intent="error" |
validate | (value, formValues) => string | string[] | null | Promise | — | Base UI Field validation callback |
validationMode | "onSubmit" | "onBlur" | "onChange" | "onSubmit" | When validate runs |
validationDebounceTime | number | 0 | Debounce for onChange validation, ms |
showCharacterCounter | boolean | false | Shows the counter (needs a limit except with counterFormat="count") |
counterFormat | "ratio" | "remaining" | "count" | "ratio" | "12 / 200" · "188 characters remaining" (flips to "N over" past the limit) · "12 characters" |
counterPlacement | "below" | "inside" | "below" | Footer row, or pinned inside the box's bottom-end |
softLimit | number | — | Recommendation: warns past it, never blocks typing |
maxLength | number | — | Native hard limit (counts UTF-16 code units; the visible counter counts graphemes) |
header | ReactNode | — | Slot inside the box, above the text — chips, identity |
footer | ReactNode | — | Slot inside the box, below the text — actions, toolbars |
clearable | boolean | false | Clear (×) button while there's a value; Escape clears too |
onClear | () => void | — | Called after a clear |
isLoading | boolean | false | Spinner, aria-busy, edits blocked via readOnly |
monospace | boolean | false | Monospaced value |
submitOn | "none" | "mod-enter" | "none" | ⌘/Ctrl+Enter submits the closest form; Enter keeps inserting newlines |
tone | "default" | "on-color" | Surface's tone | Renders for a coloured/dark plate via the surface ink contract |
minRows | number | 3 | Minimum visible rows (maps to rows) |
maxRows | number | — | Cap before scrolling — applies with and without autoResize |
autoResize | boolean | false | Grows with content; disables the resize grip |
resize | "none" | "vertical" | "horizontal" | "both" | "vertical" | Drag-grip axes; the grip resizes the whole field group |
value / defaultValue / onChange | — | — | Controlled and uncontrolled both supported |
onValueChange | (value: string) => void | — | Value-first callback alongside onChange, "" on clear |
name | string | — | Form field name (on the Base UI Field root) |
disabled / readOnly | boolean | — | Native semantics; readOnly stays focusable and selectable |
dir | "ltr" | "rtl" | "ltr" | Text direction |
wrapperClassName / containerClassName / className | string | — | Outer wrapper · bordered container · the <textarea> itself |
All other standard <textarea> attributes (placeholder, rows, wrap, spellCheck, autoFocus, enterKeyHint, …) forward to the underlying element. An explicit rows deliberately overrides minRows.
TextareaSkeleton
Same box as the real control at every size (textareaContainerVariants + skeletonShell). Props: size, rounded, variant, minRows, showLabel, showHelper, labelChars, helperChars.
The two *Chars props state how long the real text is, in characters, so the bars measure what the text measures (§2b) — they run through skeletonTextWidth, which corrects ch (the advance of "0") to the average glyph. Defaults: 14 and 28.
Notes
- Accessibility wiring — the label names the control; supporting text, info text, error and the counter's limit description all reach it via
aria-describedbyin a fixed order. The error region is a pre-mounted polite live region. Counter announcements fire only when crossing 90% and the limit, debounced — never per keystroke, and never from the counter element itself. - Grapheme counting — the visible counter counts user-perceived characters (
Intl.Segmenter), so "😄" is 1. NativemaxLengthcounts UTF-16 code units, so the enforced limit can differ from the display; the count freezes during IME composition. - Over-limit — never colour-alone: the tint pairs with an icon, and a hard over-run (long
defaultValue, controlled value) setsaria-invalid. A field filled to exactlymaxLengthis not invalid — that is the documented maximum, so the counter shows the ceiling has been reached while validity stays clean. - Resize grip — desktop only (hidden below
md), and hidden while disabled, read-only or loading. It carriestouch-action: none, so on a phone a thumb landing in that corner would resize the field instead of scrolling the page; Fluent 2 defaultsresizetononefor the same reason. Touch and keyboard users getmaxRows/autoResizeinstead. - Shell-less variants stay shell-less —
disabledandreadOnlyrepaint the well, they do not invent one:plaingains no plate and no border,underlinekeeps its rule but gains no fill. Ink, cursor and pointer-events still apply everywhere, so a disabled plain field reads and behaves as disabled; a plain field with an intent boundary mutes that boundary. - Error replaces helper — the error row swaps in for
supportingText(M3/Carbon) so layout never shifts under an open keyboard;readOnlysuppresses the error state entirely. - Don't nest in menus — a textarea consumes Up/Down and Enter, so never place one inside a
Toolbar,MenuorListboxpattern; use a dialog or popover. - In sheets — present compose sheets at full height (no half detent), per the platform guidance for growing composers.
data-slotmap —textarea(root) ·textarea-container·textarea-input·textarea-label·textarea-info·textarea-supporting·textarea-error·textarea-error-region·textarea-counter·textarea-header·textarea-footer·textarea-clear·textarea-grip·textarea-spinner. Root also stampsdata-size,data-intent,data-variant,data-rounded,data-label-placement,data-tone,data-loading,data-readonly.