Skip to content

Textarea

A multi-line text field that grows with its content.

Status
Stable
Level
Atom
Category
Inputs
Adoption
Not used yet
import { Textarea } from "@oration/canon/components/textarea";
packages/canon/src/components/textarea.tsx

Reject INV-20417?

$18,240.00 from Halcyon Logistics, due Oct 12.

Halcyon sees this in the rejection email.

import { Button } from "@oration/canon/components/button";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Hero() {    const id = React.useId();    const [reason, setReason] = React.useState("");    const [error, setError] = React.useState<string>();    return (        <form            noValidate            className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg"            onSubmit={(event) => {                event.preventDefault();                if (!reason.trim()) {                    setError(                        "Tell Halcyon why, so they can send a corrected invoice.",                    );                    document.getElementById(`${id}-reason`)?.focus();                    return;                }                setError(undefined);                toast.add({                    title: "Rejected INV-20417",                    description: "Halcyon Logistics was sent your reason.",                });                setReason("");            }}        >            <div className="flex flex-col gap-1 p-4 pb-3">                <p className="text-base leading-none font-medium text-foreground">                    Reject INV-20417?                </p>                <p className="text-sm text-muted-foreground">                    $18,240.00 from Halcyon Logistics, due Oct 12.                </p>            </div>            <div className="flex flex-col gap-2 px-4 pb-4">                <Label htmlFor={`${id}-reason`}>Reason</Label>                <Textarea                    id={`${id}-reason`}                    value={reason}                    onChange={(event) => {                        setReason(event.target.value);                        if (error) setError(undefined);                    }}                    placeholder="Duplicate of INV-20398, already paid on Sep 18."                    aria-invalid={error ? true : undefined}                    aria-describedby={`${id}-reason-description`}                    className="max-h-40 min-h-20 resize-none"                />                <p                    id={`${id}-reason-description`}                    className={cn(                        "text-13",                        error ? "text-destructive" : "text-muted-foreground",                    )}                >                    {error ?? "Halcyon sees this in the rejection email."}                </p>            </div>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button                    type="button"                    variant="ghost"                    onClick={() => {                        setReason("");                        setError(undefined);                    }}                >                    Cancel                </Button>                <Button type="submit" variant="destructive">                    Reject invoice                </Button>            </div>        </form>    );}

Usage#

Textarea is the multi-line text field. It shares Input's stroke, corners, focus ring and invalid state, starts at 64px and grows with its content through field-sizing: content. In Oration it holds notes to suppliers, dispute reasons, macro bodies and agent instructions. What people get wrong is the ceiling: without a max-h-* a long paste pushes the rest of the form off screen.

When to use

  • For free text that can run past one line: a note on a remittance, a reason for rejecting an invoice.
  • For a message template or an agent instruction that people edit in place.
  • When the length limit matters, paired with a live character count.
  • Inside a Field, with a label above and a description or error below.

When not to use

  • For a short value such as a name, an email or an ID. Use Input
  • For a composer with send, attachments, mentions and slash commands. Use Prompt bar
  • For a template with variables that need to be inserted and highlighted. Use Prompt editor
  • For editing a single value on the record in place, where the text becomes a field on click. Use Editable text
  • For a list of short values such as email addresses or keywords. Use Tag input

Grow, then scroll

Let the field grow with what people type, and cap it with max-h-* so it scrolls inside once it passes a comfortable height. The form's footer and buttons should never be pushed off screen.

Every field has a label

A visible Label above, or an sr-only one. The placeholder shows an example of a good answer, never the name of the field.

Anatomy#

  1. Container. Full width, at least 64px tall, 10px corners, a 1px Field Stroke and a transparent fill (the input color at 30% in dark).
  2. Text. 16px below 768px and 14px above, with 10px side and 8px top padding, and an indigo caret.
  3. Placeholder. Slate Meta, for an example answer.
  4. Resize handle. The browser's own corner grip. It resizes in both directions unless you set resize-y or resize-none.

Examples#

With a description

A Label above, the textarea, and a description that says who reads the text. resize-y keeps the grip but stops a sideways drag.

Northwind Freight sees this on the remittance advice.

import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function WithDescription() {    const id = React.useId();    return (        <div className="flex w-full max-w-sm flex-col gap-2">            <Label htmlFor={`${id}-note`}>Note to supplier</Label>            <Textarea                id={`${id}-note`}                placeholder="Paid by ACH on Sep 28. Covers INV-20398 and INV-20417."                aria-describedby={`${id}-note-description`}                className="resize-y"            />            <p                id={`${id}-note-description`}                className="text-13 text-muted-foreground"            >                Northwind Freight sees this on the remittance advice.            </p>        </div>    );}

Grows with content

field-sizing: content lets the field grow as lines are added. max-h-40 stops it at 160px, after which it scrolls. Insert the invoice list to see both.

Grows to 160px, then scrolls.

import { Button } from "@oration/canon/components/button";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function Grows() {    const id = React.useId();    const [note, setNote] = React.useState(        "Payment run for Friday, Oct 2 covers:",    );    const invoices = [        "INV-20398  Northwind Freight  $4,120.00",        "INV-20417  Halcyon Logistics  $18,240.00",        "INV-20422  Orchard Street Supply  $912.50",        "INV-20431  Northwind Freight  $2,760.00",        "INV-20440  Halcyon Logistics  $6,305.25",        "INV-20452  Orchard Street Supply  $1,180.00",    ];    return (        <div className="flex w-full max-w-md flex-col gap-2">            <Label htmlFor={`${id}-summary`}>Run summary</Label>            <Textarea                id={`${id}-summary`}                value={note}                onChange={(event) => setNote(event.target.value)}                className="max-h-40 resize-none tabular-nums"            />            <div className="flex items-center justify-between gap-3">                <p className="text-13 text-muted-foreground">                    Grows to 160px, then scrolls.                </p>                <Button                    type="button"                    variant="outline"                    size="sm"                    onClick={() =>                        setNote(                            (current) => `${current}\n${invoices.join("\n")}`,                        )                    }                >                    Insert invoice list                </Button>            </div>        </div>    );}

Character count

When a hard limit exists, count toward it in tabular figures and turn the field invalid past the limit, rather than cutting the text off with maxLength.

The supplier's bank shows the first 80 characters.

31 of 80 characters

import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function CharacterCount() {    const id = React.useId();    const limit = 80;    const [memo, setMemo] = React.useState("Cedarline payment for INV-20417");    const over = memo.length > limit;    return (        <div className="flex w-full max-w-sm flex-col gap-2">            <Label htmlFor={`${id}-memo`}>ACH memo</Label>            <Textarea                id={`${id}-memo`}                value={memo}                onChange={(event) => setMemo(event.target.value)}                aria-invalid={over || undefined}                aria-describedby={`${id}-memo-count`}                className="min-h-16 resize-none"            />            <div className="flex items-start justify-between gap-3 text-13">                <p className="text-muted-foreground">                    The supplier's bank shows the first 80 characters.                </p>                <p                    id={`${id}-memo-count`}                    className={cn(                        "shrink-0 tabular-nums",                        over ? "text-destructive" : "text-muted-foreground",                    )}                >                    {memo.length} of {limit} characters                </p>            </div>        </div>    );}

Dense

In settings cards and sheets, step the text to 13px with md:text-[13px] (it stays 16px on phones), start taller with min-h-24 and cap it with max-h-48.

import { Button } from "@oration/canon/components/button";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Dense() {    const id = React.useId();    const [instructions, setInstructions] = React.useState(        "Match each invoice to an open PO before approving. If the totals differ by more than 2%, hold the invoice and ask Priya Raman.",    );    return (        <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border">            <div className="flex flex-col gap-1.5">                <Label htmlFor={`${id}-instructions`} className="text-[13px]">                    Instructions                </Label>                <Textarea                    id={`${id}-instructions`}                    value={instructions}                    onChange={(event) => setInstructions(event.target.value)}                    className="max-h-48 min-h-24 resize-none md:text-[13px]"                />            </div>            <Button                type="button"                variant="outline"                size="sm"                className="self-end"                onClick={() =>                    toast.add({                        type: "success",                        title: "Instructions saved",                        description:                            "The AP agent uses them from the next invoice.",                    })                }            >                Save instructions            </Button>        </div>    );}

States#

Rest
Focus
Invalid
Disabled
import { Textarea } from "@oration/canon/components/textarea";import { cn } from "@oration/canon/lib/utils";export function StatesRow() {    const states = [        { name: "Rest", className: "" },        { name: "Focus", className: "border-ring ring-3 ring-ring/50" },        { name: "Invalid", className: "" },        { name: "Disabled", className: "" },    ];    return (        <div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">            {states.map((state) => (                <div key={state.name} className="flex min-w-0 flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <Textarea                        aria-label={`Note to supplier, ${state.name.toLowerCase()}`}                        tabIndex={-1}                        defaultValue="Paid by ACH on Sep 28."                        disabled={state.name === "Disabled"}                        aria-invalid={state.name === "Invalid" || undefined}                        className={cn(                            "pointer-events-none resize-none",                            state.className,                        )}                    />                </div>            ))}        </div>    );}
States
StateTreatment
RestField Stroke border on a transparent fill. No hover style.
FocusIndigo border and a 3px Focus Indigo ring at 50%.
GrowingHeight follows the content from 64px up to any max-h-* you set, then the field scrolls.
InvalidWith aria-invalid, a Signal Red border and a 3px red ring at 20% (50% and 40% in dark).
Disabled50% opacity on a faint input-tinted fill with a not-allowed cursor. The value is not submitted.
Read-onlyDraws like rest. See known gaps.

Behavior#

  • A native <textarea>, not a Base UI component, so it doesn't report dirty or touched state to a Base UI Field.
  • field-sizing: content sizes the box to its text. Browsers without it keep the height of rows (two lines by default, lifted to 64px by min-h-16) and scroll.
  • Set min-h-* to start taller, such as min-h-28 for a reason field, and max-h-* to stop growing.
  • Enter inserts a new line; it never submits the form.
  • Border and fill transition over 150ms; the focus ring appears at once.

Do and don't#

Do. Cap the height with max-h-* so a long paste scrolls inside the field.
Don't. Let the field grow without limit, pushing the footer and its buttons off screen.

31 of 80 characters

Do. Show a live count when a hard limit exists, such as the 80 characters an ACH addenda carries.
Don't. Cut the text off with maxLength and no count. People paste, lose the end and never know.

Content#

  • Label the field with the thing it holds: Note to supplier, Reason, Message.
  • Use the placeholder for a short example of a good answer: Duplicate of INV-20417, already paid on Sep 18.
  • Put who sees the text in the description: Northwind Freight sees this on the remittance advice.
  • Counts read 42 of 80 characters, in tabular figures, and turn Signal Red only past the limit.

Accessibility#

  • Name the field with a linked Label, and link the description, the count and any error with aria-describedby.
  • Don't announce every keystroke of a character count. Put aria-live="polite" on a message that only changes near the limit, or leave the count as a description read on focus.
  • Set aria-invalid with the error, and move focus to the field on a failed submit.
  • Keep 16px below 768px so phones don't zoom on focus; override only the md: size in dense layouts.
Keyboard interactions
KeysAction
TabMoves focus into and out of the field.
EnterInserts a new line.
ShiftTabMoves focus back without inserting anything.

Design tokens#

Design tokens
TokenUsed for
--inputField Stroke; 30% fill in dark; the disabled fill
--primaryThe caret
--muted-foregroundPlaceholder
--ringFocus border and the 3px ring at 50%
--destructiveInvalid border and ring
--radius-lg10px corners
field-sizing-contentGrows the box with its content

API reference#

Textarea

A styled native textarea. Renders data-slot="textarea".

Other props spread onto <textarea> (React.ComponentProps<"textarea">).

Props of Textarea
PropTypeDefaultDescription
valuestringNo defaultControlled value. Pair with onChange.
defaultValuestringNo defaultInitial value when uncontrolled.
onChangeReact.ChangeEventHandler<HTMLTextAreaElement>No defaultFires on every edit.
placeholderstringNo defaultAn example answer in Slate Meta.
rowsnumber2Starting height in browsers without field-sizing. min-h-* is the better control.
maxLengthnumberNo defaultA hard cap. Pair it with a visible count.
aria-invalidbooleanNo defaultDraws the red border and ring.
disabledbooleanfalseDims to 50% and blocks input.
readOnlybooleanfalseFocusable and selectable, not editable.
classNamestringNo defaultMerged after the base classes. Use it for min-h-*, max-h-*, resize-* and md:text-[13px].

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

There is no default max-h-*, so an unbounded paste grows the field until the page scrolls. Every call site has to remember the cap.

The browser's resize grip is left on in both directions; a horizontal drag can push the field past its column. Set resize-y or resize-none.

readOnly has no style of its own, the same gap as Input.

Unlike Input, it is a plain <textarea> rather than Base UI's, so it doesn't take part in Base UI Field validity.