Textarea
A multi-line text field that grows with its content.
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
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
sr-only one. The placeholder shows an example of a good answer, never the name of the field.Anatomy#
- Container. Full width, at least 64px tall, 10px corners, a 1px Field Stroke and a transparent fill (the input color at 30% in dark).
- Text. 16px below 768px and 14px above, with 10px side and 8px top padding, and an indigo caret.
- Placeholder. Slate Meta, for an example answer.
- Resize handle. The browser's own corner grip. It resizes in both directions unless you set
resize-yorresize-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#
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> );}| State | Treatment |
|---|---|
| Rest | Field Stroke border on a transparent fill. No hover style. |
| Focus | Indigo border and a 3px Focus Indigo ring at 50%. |
| Growing | Height follows the content from 64px up to any max-h-* you set, then the field scrolls. |
| Invalid | With aria-invalid, a Signal Red border and a 3px red ring at 20% (50% and 40% in dark). |
| Disabled | 50% opacity on a faint input-tinted fill with a not-allowed cursor. The value is not submitted. |
| Read-only | Draws 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: contentsizes the box to its text. Browsers without it keep the height ofrows(two lines by default, lifted to 64px bymin-h-16) and scroll.- Set
min-h-*to start taller, such asmin-h-28for a reason field, andmax-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#
max-h-* so a long paste scrolls inside the field.31 of 80 characters
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-invalidwith 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.
| Keys | Action |
|---|---|
| Tab | Moves focus into and out of the field. |
| Enter | Inserts a new line. |
| ShiftTab | Moves focus back without inserting anything. |
Design tokens#
| Token | Used for |
|---|---|
--input | Field Stroke; 30% fill in dark; the disabled fill |
--primary | The caret |
--muted-foreground | Placeholder |
--ring | Focus border and the 3px ring at 50% |
--destructive | Invalid border and ring |
--radius-lg | 10px corners |
field-sizing-content | Grows the box with its content |
API reference#
Textarea
A styled native textarea. Renders data-slot="textarea".
Other props spread onto <textarea> (React.ComponentProps<"textarea">).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | No default | Controlled value. Pair with onChange. |
defaultValue | string | No default | Initial value when uncontrolled. |
onChange | React.ChangeEventHandler<HTMLTextAreaElement> | No default | Fires on every edit. |
placeholder | string | No default | An example answer in Slate Meta. |
rows | number | 2 | Starting height in browsers without field-sizing. min-h-* is the better control. |
maxLength | number | No default | A hard cap. Pair it with a visible count. |
aria-invalid | boolean | No default | Draws the red border and ring. |
disabled | boolean | false | Dims to 50% and blocks input. |
readOnly | boolean | false | Focusable and selectable, not editable. |
className | string | No default | Merged 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.