Textarea
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.
Preview
textarea · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/textarea
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Textarea } from "@/components/ui/textarea"<Textarea
aria-label="Anything you want to add?"
placeholder="A sentence or two is plenty."
rows={4}
/>When to use it
Use it when
- Collecting a free-text note the reader writes in their own words, such as how a day went.
- A question whose answer runs to a sentence or more, where a single-line box would hide the start.
- The control inside a Field for a long-answer question, where the field owns the label and the error.
Do not use it when
- The answer is a single short line, such as a name or a place.Use
fieldinstead. - The reader is entering a measurement with a unit, which needs the number formatted and the unit shown.Use
reading-inputinstead. - The reader is choosing from a fixed set of answers rather than writing one.Use
fieldinstead.
Examples
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.
textarea-a-notes-field · base base · style base-lyraOpen under the product themeWith 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.
textarea-with-a-character-limit · base base · style base-lyraOpen under the product themeContent 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.
Do
Label "Anything you want to add?", placeholder "A sentence or two is plenty."
Don’t
No label, the question in the placeholder. It vanishes on the first keystroke.
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 anidwith a real<label htmlFor>gives it one, and the default export models the visible-label path. - With none of
aria-label,idornamepresent, a development warning names the gap rather than shipping an unnamed box. invalidsetsaria-invalidand leaves the border neutral, because a form error is not a clinical status. The message is Field'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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus into the box | One tab stop. The box does not trap Tab, so a second Tab moves out. |
| Shift+Tab | Moves focus back out of the box | A single stop in reverse too, so focus leaves rather than stepping inside. |
| Enter | Inserts a line break | Native multi-line behaviour, so Enter adds a line rather than submitting. |
| Arrow keys, Home, End | Move the cursor through the text | The browser's own text editing bindings, not ones this component adds. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from TextareaProps in registry/bases/base/textarea.tsx.
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
- Field with an input is the control for a single-line answer, such as a name or a place.
- ReadingInput is the control for a measurement with a unit, which a free-text box cannot format.