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.
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.
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.
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.
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.
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.
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.
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.
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.
| Property | Controls | Default |
|---|---|---|
--forte-textarea-rows | Shortest the control can be, in rows (the rows prop writes this) | 3 |
--forte-textarea-padding-x | Inline padding (reset per size) | var(--forte-control-px-md) |
--forte-textarea-padding-y | Block padding | calc(var(--forte-textarea-padding-x) / 2) |
--forte-textarea-font-size | Font size (reset per size) | var(--forte-font-size-2) |
--forte-textarea-line-height | Line height — also the unit the row counts above are measured in | var(--forte-line-height-normal) |
--forte-textarea-radius | Corner radius, capped at half a row so the first line is never clipped | min( var(--forte-radius-control), calc( ( 1lh + 2 * var(--forte-textarea-padding-y) + 2 * var(--forte-textarea-border-width) ) / 2 ) ) |
--forte-textarea-border-width | Border width | 1px |
--forte-textarea-bg | Background (reset per variant) | var(--forte-color-background) |
--forte-textarea-bg-hover | Background on hover | var(--forte-color-panel-hover) |
--forte-textarea-fg | Text colour | var(--forte-color-foreground) |
--forte-textarea-placeholder-fg | Placeholder colour | var(--forte-color-foreground-muted) |
--forte-textarea-border-color | Border colour (reset per variant, and by data-invalid) | var(--forte-color-border) |
--forte-textarea-border-color-hover | Border colour on hover | var(--forte-color-border-strong) |
--forte-textarea-caret-color | Caret colour | var(--forte-color-primary) |
--forte-textarea-selection-bg | Selection background | var(--forte-color-primary-soft) |
--forte-textarea-selection-fg | Selection text colour | var(--forte-color-primary-text) |
API reference
Textarea
| Prop | Type | Default | Description |
|---|---|---|---|
autoResize | boolean | false | Grow 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. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
fullWidth | boolean | false | Stretch 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. |
maxRows | number | The 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. | |
render | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, FieldControlState> | Replace the rendered element. Defaults to a `<textarea>`. | |
resize | TextareaResize | vertical | Which 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. |
rows | number | 3 | The 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. |
size | TextareaSize | md | Size 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. |
variant | TextareaVariant | outline | How 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.