DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CheckboxRadioSwitchTextareaNumberFieldOTP InputSliderCircularSliderRatingSelectPickerComboboxSearchSearchSuggestTokenizerSegmentedControl
AI agentsContributing
  1. Components
  2. Textarea

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/ui

When 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. minRows sets the starting height; autoResize with maxRows lets it grow with the text and then scroll.
  • A limit people should see. showCharacterCounter with maxLength for a hard cap, or softLimit for a recommendation that never blocks typing.
  • A label that names the field. Put what the field is in the label, and critical guidance in supportingText or infoText, never in the placeholder, which disappears once someone types.
  • A composer shell. The plain variant with header and footer slots and submitOn="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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Textarea } from "@tessinaui/ui";
example.tsx
{/* 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

PropTypeDefaultDescription
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
labelReactNode—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
requiredboolean—Asterisk + aria-required
optionalboolean—Muted "(optional)" marker; mutually exclusive with required
visuallyHiddenLabelboolean—Keep the label for AT only (composer shells)
infoTextstring—Hint at the label row's end, joined into aria-describedby
supportingTextReactNode—Helper below the field; replaced by the error row while invalid
errorMessageReactNode—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
validationDebounceTimenumber0Debounce for onChange validation, ms
showCharacterCounterbooleanfalseShows 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
softLimitnumber—Recommendation: warns past it, never blocks typing
maxLengthnumber—Native hard limit (counts UTF-16 code units; the visible counter counts graphemes)
headerReactNode—Slot inside the box, above the text — chips, identity
footerReactNode—Slot inside the box, below the text — actions, toolbars
clearablebooleanfalseClear (×) button while there's a value; Escape clears too
onClear() => void—Called after a clear
isLoadingbooleanfalseSpinner, aria-busy, edits blocked via readOnly
monospacebooleanfalseMonospaced value
submitOn"none" | "mod-enter""none"⌘/Ctrl+Enter submits the closest form; Enter keeps inserting newlines
tone"default" | "on-color"Surface's toneRenders for a coloured/dark plate via the surface ink contract
minRowsnumber3Minimum visible rows (maps to rows)
maxRowsnumber—Cap before scrolling — applies with and without autoResize
autoResizebooleanfalseGrows 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
namestring—Form field name (on the Base UI Field root)
disabled / readOnlyboolean—Native semantics; readOnly stays focusable and selectable
dir"ltr" | "rtl""ltr"Text direction
stringsPartial<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 / classNamestring—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. Native maxLength counts 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 carries touch-action: none, so a page scroll that starts in that square resizes the field instead; pass resize="none" or autoResize where a field sits in a long scrolling form on phones. It is not keyboard-operable — keyboard users get maxRows / autoResize.
  • Resize steppers — wherever the grip renders, a pair of step buttons sits start-ward of it: Shrink/Grow for vertical, Narrow/Widen for horizontal, both pairs for both. 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. An inside counter moves start-ward to clear them on touch.
  • Shell-less variants stay shell-less — disabled and readOnly repaint the well, they do not invent one: plain gains no plate and no border, underline keeps 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 so layout never shifts under an open keyboard; readOnly suppresses the error state entirely.
  • Don't nest in menus — a textarea consumes Up/Down and Enter, so never place one inside a Toolbar, Menu or Listbox pattern; 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-slot map — 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 stamps data-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. label is wired through Field; visuallyHiddenLabel keeps it for assistive tech on a composer shell with no visible label. required adds the asterisk and aria-required.
  • Descriptions join in a fixed order. Supporting text, infoText, the error and the counter's limit description all reach the control through aria-describedby.
  • Errors announce. errorMessage sets aria-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) sets aria-invalid. A field filled to exactly maxLength is not invalid; that is the documented maximum.
  • Loading sets aria-busy and blocks edits through readOnly, which keeps the value focusable and selectable. disabled and readOnly keep 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 through strings), 16px per tap, aria-controls pointing at the box, aria-disabled at 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 get maxRows / autoResize instead.
  • Contrast and touch — intent borders, helper ink and focus ring follow the Field family's audited pairs, including on a coloured Surface; axe color-contrast is clean on the demo and playground in both themes. A filled or 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

KeyBehaviour
Tab / Shift+TabMoves into and out of the field; the clear button and any header / footer controls are ordinary tab stops
EnterInserts a newline
⌘ Enter / Ctrl+EnterSubmits the closest form when submitOn="mod-enter"; otherwise inserts nothing special
EscClears the value when clearable and the field is editable
Arrow keysMove the caret — which is why a textarea must never sit inside a Toolbar, Menu or listbox

AT matrix

ATStatus
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
JAWSUNTESTED

Status stays beta until one VoiceOver and one NVDA pass are on record.

SwitchNumberField

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsSizesIntentsValidationCharacter counterAuto-resizeResizeLabel placementComposerStatesOn colorAI assistRTLAPI ReferencePropsTextareaSkeletonNotesAccessibilityKeyboardAT matrix