Skip to content
Primitiv home
Framework
Consumption mode

Textarea

stableSource Figma

A multi-line text input — the framed field consumers type longer content into.

Playground

Density

Preview

Size
import { Textarea } from "@/components/ui/textarea";
<Textarea size="md" rows={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

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

Textarea

Extends HTMLTextAreaElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRenders 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.
childrenReactNodeheadlessChild 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).
refRef<HTMLTextAreaElement>headlessForwarded to the underlying HTMLTextAreaElement. Under asChild, merged onto the rendered child via Slot.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl size; data-density scales each size further.

Styling contract

--primitiv-textarea-bg--primitiv-textarea-fg--primitiv-textarea-placeholder-color--primitiv-textarea-border-color--primitiv-textarea-border-color-focus--primitiv-textarea-border-color-invalid--primitiv-textarea-border-width--primitiv-textarea-radius--primitiv-textarea-min-height--primitiv-textarea-padding-inline--primitiv-textarea-padding-block--primitiv-textarea-font-family--primitiv-textarea-font-size--primitiv-textarea-font-weight--primitiv-textarea-line-height

Data attributes

Textarea

className: .primitiv-textarea

AttributeValueWhen
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

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.

Density
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>  <Textarea rows={4} placeholder="Tell us what happened..." />  <FieldDescription>Include the steps you took, if you can.</FieldDescription></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.

Density
import { Textarea } from "@/components/ui/textarea";
<Textarea rows={2} aria-label="Short note" placeholder="Two rows..." /><Textarea rows={6} aria-label="Long note" placeholder="Six rows..." />

Validation

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.

Density
import { Field, FieldLabel, FieldDescription, FieldErrorText } from "@/components/ui/field";import { Textarea } from "@/components/ui/textarea";
<Field invalid>  <FieldLabel>Summary</FieldLabel>  <Textarea rows={3} required maxLength={280} defaultValue="Too short" />  <FieldErrorText>Give us at least a sentence.</FieldErrorText></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.

Density
import { Textarea } from "@/components/ui/textarea";
<Textarea rows={3} disabled aria-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.

Density
import AutosizeTextarea from "react-textarea-autosize";import { Textarea } from "@/components/ui/textarea";
<Textarea asChild aria-label="Bio">  <AutosizeTextarea minRows={2} maxRows={8} /></Textarea>