Skip to content

Textarea

  • Comments, messages and descriptions
  • Any free text that may run over several lines

Not for a single line — use an Input.

Choosing between entering text:

ComponentUse it forFor example
InputOne line of text: a name, an email, a searchEmail address, search box
OTP inputA code of fixed length, typed or pasted one character per boxSMS code, two-factor code, PIN
TextareaSeveral lines of free text that may wrapComment, description, message
ComboboxTyping to find and pick from a list of known optionsChoose a city, add existing tags
FieldNot a control: wraps any of these to tie a label, a hint and an error to itAny form control with validation

Textarea is a real <textarea>. Typing, selection, spellcheck, validation and form submission are all the platform’s, so there is no headless half and nothing to keep in sync. It works with react-hook-form’s register() as-is.

Tell us why, so we can improve.
import { Textarea } from '@arun-dev/ui';
// A definite column, so the textarea's width cap resolves: an auto track would grow with
// whatever width the user drags to.
const field = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
gap: 'var(--space-3xs)',
maxInlineSize: '24rem',
};
export default function TextareaBasics() {
return (
<>
<div style={field}>
<label htmlFor="textarea-bio">Bio</label>
<Textarea id="textarea-bio" rows={4} placeholder="A few lines about yourself" />
</div>
<div style={field}>
<label htmlFor="textarea-reason">Reason for leaving</label>
<Textarea id="textarea-reason" aria-invalid aria-describedby="textarea-reason-error" />
<small id="textarea-reason-error" style={{ color: 'var(--color-status-error)' }}>
Tell us why, so we can improve.
</small>
</div>
<div style={field}>
<label htmlFor="textarea-disabled">Disabled</label>
<Textarea id="textarea-disabled" defaultValue="Read me, don't edit me" disabled />
</div>
</>
);
}
<Textarea placeholder="Write something…" />

Unlike Input, there is no box and there are no slots. The <textarea> draws its own frame, so every prop lands on it, className included. A <label htmlFor>, aria-describedby, register() and a focus ref all reach the control directly.

The field fills its container’s width by default, and the user can drag the corner handle to change both height and width. Width can shrink to --textarea-min-width and grow back to the container, but never past it, so a dragged field cannot break the layout around it. Set the starting width with className or style; the container stays the ceiling.

In a grid, give the column a size

The ceiling is max-inline-size: 100%, which holds in normal flow, in flex layouts and in grid columns with a definite size. An auto grid column instead grows to fit its content, including a width the user dragged, so the field can push past the grid. Size the column, e.g. grid-template-columns: minmax(0, 1fr).

To lock a dimension, override resize in your own CSS, e.g. resize: vertical.

autoResize makes the field grow with what is typed, from --textarea-min-height up to --textarea-max-height, then scroll. Height is no longer the user’s to set, so the handle drags width only.

import { Textarea } from '@arun-dev/ui';
// A definite column, so the textarea's width cap resolves: an auto track would grow with
// whatever width the user drags to.
const field = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
gap: 'var(--space-3xs)',
maxInlineSize: '24rem',
};
export default function TextareaAutoResize() {
return (
<div style={field}>
<label htmlFor="textarea-message">Message</label>
{/* Type or paste a few paragraphs: it grows to --textarea-max-height, then scrolls. */}
<Textarea id="textarea-message" autoResize placeholder="Type a few lines…" />
</div>
);
}

CSS only

Auto-resize is field-sizing: content, with no JavaScript measuring the text. Where a browser lacks it, the textarea keeps its rows height and scrolls, just as it would without the prop.

Uncontrolled is the default: pass defaultValue, or register(), and the value lives on the <textarea>. When something else needs the value as it is typed, such as a counter, a preview or a reset button, control it with value and onChange, exactly as with a plain <textarea>. React’s onChange fires on every keystroke, not on blur.

26 / 140
import { useState } from 'react';
import { Button, Textarea } from '@arun-dev/ui';
// A definite column, so the textarea's width cap resolves: an auto track would grow with
// whatever width the user drags to.
const field = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
gap: 'var(--space-3xs)',
maxInlineSize: '24rem',
};
const LIMIT = 140;
export default function TextareaControlled() {
const [status, setStatus] = useState('Shipping the Textarea docs');
const over = status.length > LIMIT;
return (
<div style={field}>
<label htmlFor="textarea-status">Status</label>
{/* React state owns the value: every keystroke goes through onChange and back. */}
<Textarea
id="textarea-status"
autoResize
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-invalid={over || undefined}
aria-describedby="textarea-status-count"
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<small
id="textarea-status-count"
aria-live="polite"
style={{ color: over ? 'var(--color-status-error)' : 'var(--color-text-muted)' }}
>
{status.length} / {LIMIT}
</small>
<Button onClick={() => setStatus('')}>Clear</Button>
</div>
</div>
);
}

autoResize keeps working, since it sizes to whatever value the element holds. Pass the value as value, not as children.

All of them come from the native control. There is no data-* attribute to set:

StateSet it with
focusthe platform (:focus-visible)
invalidaria-invalid on the textarea
disableddisabled on the textarea

Point aria-describedby at the error text too, so it is read out with the field.

Textarea props
PropTypeDefaultDescription
autoResizebooleanfalseGrow with the content between --textarea-min-height and --textarea-max-height, then scroll. CSS only (field-sizing: content); browsers without it keep the rows height.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
  • Set the starting height with rows, and cap input with maxLength. Both are native.
  • Restyle every textarea with the --textarea-* tokens, e.g. --textarea-min-height or --textarea-border-color-focus. They match Input’s by default but are independent: changing --input-* does not move textareas.