Skip to content

Textarea

The control you reach for when the answer is a paragraph rather than a line. It is a real <textarea>, so placeholder, required, minLength, maxLength, wrap and the rest work exactly as they do on the platform — what the library adds is the design system's metrics, the two-tone focus ring, height expressed in rows, and the wiring that makes it participate in a Field without a line of glue.

textarea/basic.tsx

Markdown is supported. Drag the bottom edge for more room.

Base UI has no textarea primitive. This one is built on Field.Control — literally the same component Input is — rendered as a <textarea> instead of an <input>. That is why it needs no wiring either: inside a <Field.Root> it picks up the label, the description, the error message, the name used on submit, and the validity attributes.

Import

import { Textarea } from "@forte-ui/react";

Examples

Sizes

size moves inline padding, block padding and font size together, and the numbers come from the same --forte-control-* tokens Input and Select.Trigger read — so a textarea stacked under an input in a form shares its optical inset at every data-forte-density setting.

textarea/sizes.tsx

What size does not move is the height, because height here is counted in rows and a row is one line of this control's own text. The three boxes below are all rows={2} and all different heights, which is the point: a row is 1lh, so the arithmetic re-does itself at every size and every density without a pixel being written down.

Variants

variant changes only how much weight the control carries: outline reads as a form control, soft as a filled field, ghost as an inline affordance — the same three Input and the select trigger have.

textarea/variants.tsx

Growing with the content

autoResize grows the box as the user types, between rows and maxRows. Past maxRows it scrolls rather than growing — which is the whole reason to set one: an unbounded composer eventually pushes its own Send button off the bottom of the screen.

textarea/auto-resize.tsx

Grows between 2 and 8 rows, then scrolls.

Fixed at 2 rows, with a handle to drag.

It is one CSS declaration where the browser has it. field-sizing: content sizes the box from its content during layout, so there is no measurement, no reflow per keystroke and no inline height fighting the stylesheet — and the min-block-size / max-block-size computed from rows and maxRows clamp it without any extra code.

autoResize forces resize to "none", and the data-resize attribute reports that rather than what you passed: a drag handle whose value the next keystroke overwrites is not a control.

A character counter

maxLength is enforced by the browser; the counter only reports it. Leaving enforcement to the platform is what keeps paste, undo and IME composition correct — a slice() in an onChange handler breaks all three.

textarea/character-count.tsx

Shown on your public profile.114 left

The count sits inside <Field.Description>, which means it is already in the textarea's aria-describedby and is read out with the field. It is deliberately not an aria-live region: it changes on every keystroke, and a screen reader announcing "142 left" after each one buries the letters the user is actually typing.

A comment composer

The shape most textareas in an app actually take: one row at rest, soft so it reads as part of the thread rather than as a form, a ceiling so a long comment scrolls instead of pushing Post out of reach, and a modifier shortcut to send.

textarea/composer.tsx
  • Bea Rivera

    The row bounds are what sold me — no more jumping submit button.

Ada Lovelace
+Enter to post

States

Disabled and read-only are not the same thing and do not look the same. Disabled drops to 55% opacity, takes cursor: not-allowed, and loses the resize handle — a control that cannot be edited must not offer a drag that does nothing. Read-only keeps full contrast, a panel-coloured background, the text cursor and the handle: selecting, copying and reading a long value is the point of the state, and a taller box makes all three easier.

textarea/states.tsx

In a form

Validity arrives on data-invalid from Field.Control, whether the rule is required, a native constraint like minLength, or a custom validate on the field. Only the boundary and the caret recolour — turning the value text danger red as well would make the user's own answer look like the error message, and on a textarea that is a whole paragraph of it.

textarea/validation.tsx

Accessibility

A textarea needs an accessible name, and <Field.Label> is the way to give it one — it renders a real <label> wired with htmlFor, so clicking the text focuses the control. Where the design has no visible label — a composer is the usual case — aria-label on the textarea is the fallback.

Placeholder text is coloured --forte-color-foreground-muted (5.82:1) rather than --forte-color-foreground-subtle (3.73:1). SC 1.4.3 has no exemption for placeholders in an enabled control, and Firefox's own 0.54 ::placeholder opacity is reset for the same reason.

There is no forte-target here: the SC 2.5.8 floor is about pointer targets under 24px, and the shortest box this component can produce — size="sm", rows={1}, compact density — is taller than that on its own.

In forced-colors mode the control paints Field / FieldText with a FieldText boundary. That last part matters for soft and ghost, whose border is transparent — a value forced-colors replaces with a system colour of its choosing rather than leaving invisible, so without the restatement the three variants would stop looking like the same control.

Theming

Every --forte-textarea-* property is declared on the textarea element itself, and an element's own declaration beats an inherited one — so setting these on :root, on a theme scope or on any ancestor does nothing. Set them on the textarea, through className (an unlayered rule beats @layer forte.components whatever its specificity) or an inline style.

The two row counts are knobs like any other, which is what lets a consumer set a house-wide minimum height from a stylesheet without touching JSX:

[data-forte="textarea"] {
  --forte-textarea-rows: 5;
  --forte-textarea-max-rows: 12;
}

--forte-textarea-max-rows defaults to infinity rather than to a large number, so max-block-size resolves to the largest length the engine supports and the knob is a complete statement on its own — no ceiling until you name one.

The global tokens these resolve to — --forte-color-*, --forte-control-*, --forte-radius-* — are the exception: this component only reads those, so re-pointing them from :root or a theme scope moves every textarea at once.

--forte-textarea-radius is the one that does not read its token straight: it is min(var(--forte-radius-control), <half a row>). A control radius is drawn for a box whose text sits on one line, in the middle, where the corner is at its widest — so data-forte-radius="pill" asks for a capsule and an input is fine. A textarea's first and last lines sit at the corners, and a capsule arc slices them. The cap is the roundest corner that still leaves every row readable; soft and the default are below it and pass through untouched.

Theming tokens for Textarea
PropertyControlsDefault
--forte-textarea-rowsShortest the control can be, in rows (the rows prop writes this)3
--forte-textarea-padding-xInline padding (reset per size)var(--forte-control-px-md)
--forte-textarea-padding-yBlock paddingcalc(var(--forte-textarea-padding-x) / 2)
--forte-textarea-font-sizeFont size (reset per size)var(--forte-font-size-2)
--forte-textarea-line-heightLine height — also the unit the row counts above are measured invar(--forte-line-height-normal)
--forte-textarea-radiusCorner radius, capped at half a row so the first line is never clippedmin( var(--forte-radius-control), calc( ( 1lh + 2 * var(--forte-textarea-padding-y) + 2 * var(--forte-textarea-border-width) ) / 2 ) )
--forte-textarea-border-widthBorder width1px
--forte-textarea-bgBackground (reset per variant)var(--forte-color-background)
--forte-textarea-bg-hoverBackground on hovervar(--forte-color-panel-hover)
--forte-textarea-fgText colourvar(--forte-color-foreground)
--forte-textarea-placeholder-fgPlaceholder colourvar(--forte-color-foreground-muted)
--forte-textarea-border-colorBorder colour (reset per variant, and by data-invalid)var(--forte-color-border)
--forte-textarea-border-color-hoverBorder colour on hovervar(--forte-color-border-strong)
--forte-textarea-caret-colorCaret colourvar(--forte-color-primary)
--forte-textarea-selection-bgSelection backgroundvar(--forte-color-primary-soft)
--forte-textarea-selection-fgSelection text colourvar(--forte-color-primary-text)

API reference

Textarea

Props for Textarea
PropTypeDefaultDescription
autoResizebooleanfalseGrow with the content as the user types, between `rows` and `maxRows`. Implemented with the CSS `field-sizing` property where the browser has it, so there is no measurement, no reflow per keystroke and no inline height on the element. Older engines fall back to a resize observer and a `scrollHeight` read, which behaves identically and costs a layout per change. Forces `resize` to `"none"`: a handle whose value the next keystroke overwrites is not a control.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
fullWidthbooleanfalseStretch the control to fill the width of its container. Only needed outside a `Field.Root` — a field is a flex column, so a textarea inside one already stretches.
maxRowsnumberThe number of rows the box may grow to before it starts scrolling instead. Only a ceiling: a textarea shorter than this is unaffected. Unset means no ceiling.
onValueChange((value: string, eventDetails: { reason: "none"; event: Event; cancel: () => void; allowPropagation: () => void; isCanceled: boolean; isPropagationAllowed: boolean; trigger: Element; }) => void)Callback fired when the value changes. Use when controlled.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, FieldControlState>Replace the rendered element. Defaults to a `<textarea>`.
resizeTextareaResizeverticalWhich axes the user may drag the control along. `vertical` is the default because `both` lets the box escape the column it sits in — legitimate for a full-width composer, wrong for a field in a form.
rowsnumber3The number of rows the box is at its shortest. Sets the native `rows` attribute and the floor the control can never shrink below — including under `autoResize`, and including a drag on the resize handle.
sizeTextareaSizemdSize of the control. Inline padding, block padding and font size move together, and the numbers come from the same `--forte-control-*` tokens `Input` and `Select.Trigger` read — so a textarea stacked under an input shares its optical inset at every `data-forte-density` setting. A `<textarea>` has no native `size` attribute, so unlike `Input` this shadows nothing.
variantTextareaVariantoutlineHow much visual weight the control carries. `outline` reads as a form control, `soft` as a filled field, `ghost` as an inline affordance — the same three `Input` has.

Everything else is a native <textarea> prop and passes straight through, plus Base UI's render, onValueChange and defaultValue.