opsinjs
ComponentsActions and forms

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

Theme
Density
Text
Status
textarea · base base · style base-lyraOpen under the product theme

Installation

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 field instead.
  • The reader is entering a measurement with a unit, which needs the number formatted and the unit shown.Use reading-input instead.
  • The reader is choosing from a fixed set of answers rather than writing one.Use field instead.

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.

Theme
Density
Text
Status
textarea-a-notes-field · base base · style base-lyraOpen under the product theme

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.

Theme
Density
Text
Status
textarea-with-a-character-limit · base base · style base-lyraOpen under the product theme

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.

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 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'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.

KeyActionNotes
TabMoves focus into the boxOne tab stop. The box does not trap Tab, so a second Tab moves out.
Shift+TabMoves focus back out of the boxA single stop in reverse too, so focus leaves rather than stepping inside.
EnterInserts a line breakNative multi-line behaviour, so Enter adds a line rather than submitting.
Arrow keys, Home, EndMove the cursor through the textThe browser's own text editing bindings, not ones this component adds.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below 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.

  • 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.

On this page