Textarea

July 6, 2026 · View on GitHub

Multi-line text entry for longer free-form input.

Contract

Textarea captures multi-line text — descriptions, messages, long-form fields. It shares Input's size/state grammar so an Input and Textarea with the same size sit flush in a form column. It adds two features Input lacks: a resize handle (both axes by default) and a character counter for length-constrained fields.

Anatomy

Container (host)
├─ Textarea field (native <textarea>, fills host; flex 1)
├─ Counter (absolute bottom-right, when showCounter is true)
└─ Grip (absolute bottom-right, custom SVG — shown when resize !== 'none')
  • Container / host — border, radius, background; hosts the textarea and absolute overlays
  • Textarea field — native `
  • \text{Counter} — "\text{N}/\text{maxLength}" \text{text}, \text{absolute} \text{bottom}-\text{right}, \text{color} \text{gray}/400, \text{has} \text{the} \text{host}'\text{s} \text{background} \text{color} \text{so} \text{it} \text{reads} \text{cleanly} \text{over} \text{text}
  • \text{Grip} — 10 \times 10 \text{SVG} \text{with} \text{two} \text{diagonal} \text{strokes}, \text{absolute} \text{bottom}-\text{right}, $pointer-events: noneso the native resize drag still fires through it (the native::-webkit-resizer` is visually hidden)

API

Inputs

NameTypeDefaultDescription
size'sm' | 'md' | 'lg''md'Component size — matches Input sm/md/lg
placeholderstring''Placeholder text
rowsnumber3Default visible rows
maxLengthnumber | nullnullCharacter limit (shown by counter)
showCounterbooleanfalseShow the "N/maxLength" counter
resize'both' | 'vertical' | 'horizontal' | 'none''both'Direction the user can drag to resize
filledbooleanfalseFilled variant (gray bg, no border)
disabledbooleanfalseNon-interactive
forceStateKpState | nullnullForce visual state — Storybook only

Outputs

Implements ControlValueAccessor — use [(ngModel)] or reactive-form bindings. No custom outputs.

Content projection

None — Textarea is a leaf component.

Variants

DimensionValues
Sizesm (min-height 60), md (min-height 72), lg (min-height 88)
Variantdefault (white + border), filled (gray bg, transparent border)
Staterest, hover, active, focus, disabled, error
Resizeboth (default), vertical, horizontal, none

States

StateBehavior
restDefault idle, gray border
hoverBorder darkens
activeBorder darkens further — while typing
focusBorder turns primary blue
disabledGray bg, muted text, aria-disabled="true"
errorRed border

Accessibility

  • Role: native <textarea> — implicit role textbox (multiline)
  • Keyboard: all native textarea keys; Tab escapes out of the field (no indent behavior)
  • ARIA:
    • aria-invalid="true" when in error state
    • aria-disabled="true" when disabled
    • aria-label required for standalone use; otherwise labeled by surrounding FormField
    • When maxLength is set: aria-describedby should reference the counter for screen-reader context
  • Screen reader: announces label, role "multiline edit", current value; counter read as context when focused near the limit

Do / Don't

Do

  • Wrap in FormField for label + helper + error messaging
  • Use maxLength with showCounter for length-limited content (tweets, SMS, summaries)
  • Match Textarea size with Input size in the same form column
  • Leave resize='both' in most form contexts — users usually want vertical, occasionally horizontal
  • Switch to resize='vertical' in narrow columns where widening would break the layout

Don't

  • Don't use Textarea for single-line input — use Input
  • Don't hide the counter when maxLength is set — users need to know the constraint
  • Don't disable resize entirely unless the surrounding layout strictly requires a fixed height
  • Don't set maxLength arbitrarily low — it's a real constraint, not a style choice
  • Don't override counter / grip positioning via custom CSS — use component props

References

Changelog

  • 0.1.0 — Initial component with 3 sizes, vertical resize, optional counter, CVA integration
  • 0.2.0 — Replace native resize grip with custom 10×10 SVG grip; thin custom scrollbar (4px + 2px inset)
  • 0.3.0 — Resize defaults to 'both'; add 'horizontal' option alongside 'vertical' and 'none'