A multi-line text input — the framed field consumers type longer content into.
Playground
Preview
import{Textarea}from"@/components/ui/textarea";<Textareasize="md"rows={4}placeholder="Tell us what happened..."/>
import{Textarea}from"@primitiv-ui/react";<Textarearows={4}placeholder="Tell us what happened..."/>
Density is set by a data-density ancestor — the Context system, not a Textarea prop.
Installation
npx primitiv add textarea
pnpm dlx primitiv add textarea
yarn dlx primitiv add textarea
bunx primitiv add textarea
Import
import{Textarea}from"@/components/ui/textarea";
Copied into your project as .primitiv-textarea — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Textarea
Extends HTMLTextAreaElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Renders the child element instead of a native <textarea>, merging all
props — aria-, data-, event handlers, ref — onto it via Slot.
Event handlers compose (child runs first). Use this to wrap a third-party
autosizing textarea while keeping this component's prop contract.
children
ReactNode
—
headless
Child content. Under asChild, must be a single React element that
Slot merges props onto. Not used when rendering a native
<textarea> (the element's content is controlled by value /
defaultValue, not children).
ref
Ref<HTMLTextAreaElement>
—
headless
Forwarded to the underlying HTMLTextAreaElement. Under asChild, merged
onto the rendered child via Slot.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Control size; data-density scales each size further.
Styling contract
15 CSS custom properties on .primitiv-textarea — mode-agnostic. These names are the stable surface; the values are not.
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
Textarea
className:.primitiv-textarea
Attribute
Value
When
data-disabled
""
disabled
Textarea
Attribute
Value
When
data-disabled
""
disabled
Accessibility
A <textarea> has no implicit accessible name. Give it one every time — a <label htmlFor>, aria-label, or aria-labelledby. Field is the path of least resistance because it generates the id and the wiring, so the two cannot drift apart.
Placeholder text is not a label. It disappears the moment someone types, it is announced inconsistently, and it usually fails contrast — use it for an example of the format, never for the field's name.
disabled removes the field from the tab order entirely, so a screen-reader user browsing by control will not find it. If the reason it is unavailable matters, say so in visible text near the field rather than relying on the disabled state to communicate it.
aria-invalid is for errors the browser cannot know about — a server rejection, a validation library. Native constraints (required, maxLength) already set :invalid themselves, and doubling them up makes the field announce as invalid before anyone has typed.
A character limit needs to be announced, not just enforced. maxLength silently stops input at the cap; pair it with description text (through Field, so it is linked by aria-describedby) that states the limit up front.
The field resizes on the block axis only. That is a deliberate constraint rather than a missing feature — the width tracks the form column, so a free-axis handle would let someone drag the field out of the layout — but it does mean a user cannot widen it, so avoid content that genuinely needs a wide measure.
Under asChild the element and its semantics are yours; nothing about the component overrides them. That is what makes it safe to wrap an autosizing textarea — it is still a real <textarea> underneath.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
Labelling
A <textarea> has no implicit accessible name, so every one needs a label — a <label htmlFor>, an aria-label, or aria-labelledby. Field does the wiring: it generates the id, points the label at it, and links the description through aria-describedby, so the three stay in step when the field is replaced.
Include the steps you took, if you can.
import{Field,FieldLabel,FieldDescription,FieldErrorText}from"@/components/ui/field";import{Textarea}from"@/components/ui/textarea";<Field><FieldLabel>What went wrong?</FieldLabel><Textarearows={4}placeholder="Tell us what happened..."/><FieldDescription>Include the steps you took, if you can.</FieldDescription></Field>
import{Field}from"@primitiv-ui/react";import{Textarea}from"@primitiv-ui/react";<Field><Field.Label>What went wrong?</Field.Label><Textarearows={4}placeholder="Tell us what happened..."/><Field.Description>Include the steps you took, if you can.</Field.Description></Field>
Height, and who controls it
Three different things set the height, and only one of them is a Primitiv prop. rows is the native attribute and sets the initial height. size (the contract prop, in the playground above) sets the type scale and a min-height floor the field will not shrink below. And the user sets the rest — the styled surface allows vertical resizing only, because the width already fills the form column, so a free-axis handle would just let someone drag the field out of its own layout. Drag the bottom edge of either field below.
Native constraints work exactly as the browser intends — required, maxLength, minLength — because the component does not interfere with them; the browser sets :invalid itself and blocks submission. aria-invalid is the separate, deliberate hook for an error your server or validation library found, and it is what the stylesheet keys the invalid ring off. Inside a Field, invalid sets it for you and links the error text through aria-describedby.
Give us at least a sentence.
import{Field,FieldLabel,FieldDescription,FieldErrorText}from"@/components/ui/field";import{Textarea}from"@/components/ui/textarea";<Fieldinvalid><FieldLabel>Summary</FieldLabel><Textarearows={3}requiredmaxLength={280}defaultValue="Too short"/><FieldErrorText>Give us at least a sentence.</FieldErrorText></Field>
import{Field}from"@primitiv-ui/react";import{Textarea}from"@primitiv-ui/react";<Fieldinvalid><Field.Label>Summary</Field.Label><Textarearows={3}requiredmaxLength={280}defaultValue="Too short"/><Field.ErrorText>Give us at least a sentence.</Field.ErrorText></Field>
Disabled
disabled forwards the native attribute — blocking input and removing the field from the tab order — and sets data-disabled so CSS can style it without depending on the :disabled pseudo-class. Inside a Field, setting it on the field disables the control and dims the label with it, so you set it in one place.
import{Textarea}from"@/components/ui/textarea";<Textarearows={3}disabledaria-label="Notes"defaultValue="Read-only for now."/>
import{Textarea}from"@primitiv-ui/react";<Textarearows={3}disabledaria-label="Notes"defaultValue="Read-only for now."/>
As another element (asChild)
asChild renders your element instead of the native <textarea>, merging every prop onto it — aria-*, data-*, handlers (yours runs first) and the ref. It exists here for one concrete case the component deliberately does not solve itself: autosizing. Reach for a library that grows the field as you type and keep this prop contract, rather than asking Primitiv to own a measurement loop.