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.
pnpm add @tessinaui/uiWhen to use
- Free-form, multi-line text: a bio, a comment, feedback, a description. If people may write more than a line, this is the control.
- Size it to the answer you expect.
minRowssets the starting height;autoResizewithmaxRowslets it grow with the text and then scroll. - A limit people should see.
showCharacterCounterwithmaxLengthfor a hard cap, orsoftLimitfor a recommendation that never blocks typing. - A label that names the field. Put what the field is in the
label, and critical guidance insupportingTextorinfoText, never in the placeholder, which disappears once someone types. - A composer shell. The
plainvariant withheaderandfooterslots andsubmitOn="mod-enter"covers a comment box with an action row.
When not to use
- For a single-line value such as a name, phone number or email. Use a single-line input.
- For a hard open-ended question. Break it into a series of simpler questions, with Radio where people can choose from options.
- For a full chat composer with sending, stopping and attachments. Use PromptInput; Textarea supplies only the shell.
- For a list of short values. Use Tokenizer where each typed value becomes a removable chip.
- Inside a Toolbar, menu or listbox. A textarea consumes Up, Down and Enter, so use a Modal or Popover instead.
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. Step buttons beside the grip do the same with a tap, 16px at a time: hover the grip to reveal them with a mouse; on touch they show while the field has focus. 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.
RTL
dir="rtl" aligns the label and helper rows right, puts the character counter on the left, and moves the clear button, resize grip and step buttons to the left corners. The grip's glyph flips, dragging it left widens the field, and the Widen/Narrow chevrons point the same way. Leave dir unset and the textarea follows the page's direction.
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 on Field's ladder (0/4/6/8/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" | Resize axes; the grip and its step buttons resize 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 |
strings | Partial<TextareaStrings> | English, Arabic under dir="rtl" | Override the clear button's name — clear, or clearField ("Clear {label}") when the textarea has a string label — the resize steppers' shrink · grow · narrow · widen, the optional marker, and the counter's text and announcements: upToCharacters / upToCharactersRecommended, charactersRemaining, characterOver / charactersOver, characterCount / charactersCount, limitReached, recommendedReached. A count takes plural variants, key_<category> (charactersRemaining_few), picked by Intl.PluralRules; overriding a bare key drops the Arabic table's variants of it |
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 — they run through skeletonTextWidth, which corrects ch (the advance of "0") to the average glyph. Defaults: 14 and 28.
Notes
- 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. - Resize grip — shows on every pointer, and hides while disabled, read-only, loading or
autoResize. On touch the target grows to a 44px square pinned inside the box corner. It carriestouch-action: none, so a page scroll that starts in that square resizes the field instead; passresize="none"orautoResizewhere a field sits in a long scrolling form on phones. It is not keyboard-operable — keyboard users getmaxRows/autoResize. - Resize steppers — wherever the grip renders, a pair of step buttons sits start-ward of it: Shrink/Grow for
vertical, Narrow/Widen forhorizontal, both pairs forboth. Each tap moves the edge 16px, with the drag's floors (48px high, the helper rows wide); widening also stops at the parent's width. With a mouse they appear when you hover the grip. On touch they are 44px buttons that show while the field has focus, so at rest they never cover text. Pressing one does not move focus, so the caret and a phone's keyboard stay put. Aninsidecounter moves start-ward to clear them on touch. - 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
supportingTextso 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 a compose sheet at full height, never at a half detent: the field grows with what is typed, and a half-height sheet leaves it no room.
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-steppers·textarea-grow·textarea-shrink·textarea-widen·textarea-narrow·textarea-spinner. Root also stampsdata-size,data-intent,data-variant,data-rounded,data-label-placement,data-tone,data-loading,data-readonly.
Accessibility
Textarea is a native <textarea> on the Base UI Field
contract, so it has no APG widget pattern of its own — the semantics are the
platform's, and the work is in the wiring around them.
- The label names the control.
labelis wired through Field;visuallyHiddenLabelkeeps it for assistive tech on a composer shell with no visible label.requiredadds the asterisk andaria-required. - Descriptions join in a fixed order. Supporting text,
infoText, the error and the counter's limit description all reach the control througharia-describedby. - Errors announce.
errorMessagesetsaria-invalid, and the error row lives in a polite live region that is mounted before any error arrives, so an error that appears is spoken. The error replaces the helper line rather than stacking under it, so the layout does not shift under an open keyboard. - The counter never chatters. Its announcements come from a separate,
pre-mounted
role="status", debounced, and only when crossing 90% of the limit and the limit itself — never per keystroke, and never from the visible counter. - Over-limit is never colour-alone — the tint pairs with an icon, and a hard
over-run (a long
defaultValue, a controlled value) setsaria-invalid. A field filled to exactlymaxLengthis not invalid; that is the documented maximum. - Loading sets
aria-busyand blocks edits throughreadOnly, which keeps the value focusable and selectable.disabledandreadOnlykeep their native semantics. - The clear button is named "Clear" plus the label when the label is a string.
- Resizing never requires a drag (WCAG 2.5.7). The grip works with mouse, pen and
touch, with a 44px target on touch, and its step buttons are the single-pointer
alternative: "Shrink text box" / "Grow text box" (and "Narrow" / "Widen"; Arabic
under
dir="rtl", replaceable throughstrings), 16px per tap,aria-controlspointing at the box,aria-disabledat a floor. They stay hit-testable there, so a tap never falls through into the text. The grip is hidden from assistive tech. The steppers are named buttons but leave the tab order (tabIndex={-1}), so a form gains no extra tab stops. Keyboard users getmaxRows/autoResizeinstead. - Contrast and touch — intent borders, helper ink and focus ring follow the Field
family's audited pairs, including on a coloured
Surface; axecolor-contrastis clean on the demo and playground in both themes. Afilledor read-only well is grey, so it takes the darker muted ink for everything drawn on it — the placeholder, the inside label and the inside counter clear 4.5:1 there. Type holds a 16px floor on phones so iOS does not zoom on focus.
Keyboard
| Key | Behaviour |
|---|---|
Tab / Shift+Tab | Moves into and out of the field; the clear button and any header / footer controls are ordinary tab stops |
Enter | Inserts a newline |
⌘ Enter / Ctrl+Enter | Submits the closest form when submitOn="mod-enter"; otherwise inserts nothing special |
Esc | Clears the value when clearable and the field is editable |
| Arrow keys | Move the caret — which is why a textarea must never sit inside a Toolbar, Menu or listbox |
AT matrix
| AT | Status |
|---|---|
| VoiceOver + Safari (macOS) | Passed 2026-09-14. Focus reads "Bio, Up to 160 characters, text entry area" — the limit rides the description, not a per-keystroke count |
| VoiceOver + Chrome (macOS) | UNTESTED |
| NVDA + Chrome (Windows) | UNTESTED — no Windows host available |
| JAWS | UNTESTED |
Status stays beta until one VoiceOver and one NVDA pass are on record.