---
title: "Textarea"
description: "A multi-line box for a free-text note, a native textarea wearing the opsinjs tokens rather than a Base UI primitive, that takes neither colour axis."
url: "https://opsinjs.pensievelabs.org/components/textarea"
source: "https://opsinjs.pensievelabs.org/components/textarea.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["long text", "notes input"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="textarea"
  status="shipped"
  questions="[
  &#x22;Whether the body ink, the muted placeholder and the neutral border clear the text and non-text contrast floors is argued from the roles, not measured.&#x22;,
  &#x22;Nobody has heard the box in a screen reader, so whether an unnamed box is caught rests on a development warning that only fires in development.&#x22;,
  &#x22;Whether a standalone invalid box is noticeable with only aria-invalid and the neutral border, before Field places a visible message, has not been tested with a reader.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="textarea" />

## Installation [#installation]

<ComponentInstall name="textarea" unbuilt="false" importPath="@/components/ui/textarea" />

## Usage [#usage]

```tsx
import { Textarea } from "@/components/ui/textarea"
```

```tsx
<Textarea
  aria-label="Anything you want to add?"
  placeholder="A sentence or two is plenty."
  rows={4}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Collecting a free-text note the reader writes in their own words, such as how a day went.&#x22;,
  &#x22;A question whose answer runs to a sentence or more, where a single-line box would hide the start.&#x22;,
  &#x22;The control inside a Field for a long-answer question, where the field owns the label and the error.&#x22;,
]"
  avoid="[
  { case: &#x22;The answer is a single short line, such as a name or a place.&#x22;, instead: &#x22;field&#x22; },
  { case: &#x22;The reader is entering a measurement with a unit, which needs the number formatted and the unit shown.&#x22;, instead: &#x22;reading-input&#x22; },
  { case: &#x22;The reader is choosing from a fixed set of answers rather than writing one.&#x22;, instead: &#x22;field&#x22; },
]"
/>

## Examples [#examples]

### A notes field [#a-notes-field]

The case Textarea was built for, standalone rather than inside a Field. `aria-label`
carries the prompt, and the placeholder shows the kind of answer wanted rather than
repeating the question.

<ComponentPreview name="textarea-a-notes-field" kind="example" align="start" />

### With a character limit [#with-a-character-limit]

`maxLength` caps the text, and the footnote states the limit in words, because the
native attribute refuses the next keystroke in silence. This box is named by a real
`<label htmlFor>` pointed at its `id`.

<ComponentPreview name="textarea-with-a-character-limit" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Put the question in a label or `aria-label`, never only in the placeholder, which is
grey and vanishes the moment a reader types. Use the placeholder for an example of the
answer. State a length limit in words before the reader reaches it, and count down to
the ceiling rather than up from zero.

<DoDont>
  <DoDont.Do>
    **Label "Anything you want to add?", placeholder "A sentence or two is plenty."**
  </DoDont.Do>

  <DoDont.Dont>
    **No label, the question in the placeholder.** It vanishes on the first keystroke.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA in a source pass and a rendered pass.** The audit is
author-run and is not an independent review, and clinical review is still pending. The
rendered pass on the component view was clean: no axe violations, no target-size hit, no
focus-visible failure, and no reflow at 320px. The source pass confirmed the same, so the
audit found nothing to change and no fix was applied.

The box draws only neutral chrome roles, so no status colour, colour literal or ramp step
reaches it, and the type is the `body` step rather than a pixel size.

* The box needs an accessible name and invents none. A Field label, an `aria-label`, or
  an `id` with a real `<label htmlFor>` gives it one, and the default export models the
  visible-label path.
* With none of `aria-label`, `id` or `name` present, a development warning names the gap
  rather than shipping an unnamed box.
* `invalid` sets `aria-invalid` and leaves the border neutral, because a form error is
  not a clinical status. The message is [Field](./field.mdx)'s to place, so standalone
  the box cannot associate an external message id.
* The focus ring rides in the box's own classes, so a project without the product
  stylesheet keeps it. Height floors at the house target.

Known residual items a reader should weigh. The box accepts an `aria-label` on its own,
and a persistent visible label is the recommended enhancement rather than a requirement,
which is why the default export uses one. `maxLength` is forwarded but the box renders no
visible counter and no live remaining-count region, so pair the limit with a visible count
near the box as the Content guidelines say. Disabled state uses a muted fill rather than an
opacity wash so the text stays legible, and WCAG exempts disabled controls from the contrast
floor.

**Not checked by any gate.** Every contrast pair, and what the box draws under
`forced-colors: active` and `prefers-contrast: more`, including the browser's own
resize handle.

<KeyboardTable
  name="textarea"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus into the box&#x22;,
    notes: &#x22;One tab stop. The box does not trap Tab, so a second Tab moves out.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back out of the box&#x22;,
    notes: &#x22;A single stop in reverse too, so focus leaves rather than stepping inside.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Inserts a line break&#x22;,
    notes: &#x22;Native multi-line behaviour, so Enter adds a line rather than submitting.&#x22;,
  },
  {
    keys: &#x22;Arrow keys, Home, End&#x22;,
    action: &#x22;Move the cursor through the text&#x22;,
    notes: &#x22;The browser's own text editing bindings, not ones this component adds.&#x22;,
  },
]"
/>

<ContrastReport component="textarea" />

## API reference [#api-reference]

<PropsTable name="TextareaProps" />

`value` with `onChange` makes the box controlled; `defaultValue` leaves it owning its
own text. `rows` sets the resting height and defaults to three. `invalid` sets
`aria-invalid` and draws no colour. `className` is merged last and must resolve no
`--opsin-status-*` or `--opsin-category-*` role.

## Related [#related]

* [Field](./field.mdx) with an input is the control for a single-line answer, such as a
  name or a place.
* [ReadingInput](./reading-input.mdx) is the control for a measurement with a unit,
  which a free-text box cannot format.
