Textarea

A thin, ref-forwarding wrapper around the native <textarea> element. The component owns the visual surface — border, padding, minimum height, focus-ring, invalid-state — and forwards every other native attribute (value, onChange, placeholder, name, disabled, readOnly, rows, cols, maxLength, …) verbatim. Three CVA variants × three sizes × an opt-in autoResize switch cover the full multi-line input surface without a per-mode wrapper.

Preview
Variants
Sizes
Auto-resize
States

Installation

npx shadcn@latest add https://craftbits.dev/r/textarea.json

Usage

Controlled — pair value with onChange so a parent owns the text:

import { Textarea } from "@craft-bits/core";
 
const [bio, setBio] = useState("");
 
<Textarea
  value={bio}
  onChange={(event) => setBio(event.target.value)}
  placeholder="Tell us about yourself"
  rows={4}
/>

Uncontrolled — let the textarea own the value, seed it via defaultValue:

<Textarea defaultValue="seeded" placeholder="Notes" />

Auto-resize — pipe through CSS field-sizing: content so the textarea grows with content (no measurement effect, no ResizeObserver, no extra JS):

<Textarea autoResize placeholder="Type to grow…" />

Compose with Field so the label, helper text, and inline error wire automatically — Field.Control injects id, aria-describedby, aria-invalid, and disabled onto the textarea:

import { Field, Textarea } from "@craft-bits/core";
 
<Field.Root invalid={Boolean(bioError)}>
  <Field.Label>Bio</Field.Label>
  <Field.Control>
    <Textarea autoResize placeholder="Tell us about yourself" />
  </Field.Control>
  <Field.Error>{bioError}</Field.Error>
</Field.Root>

Understanding the component

  1. Matched to Input. The variant matrix (outline / filled / ghost) and size scale (sm / md / lg) are byte-for-byte identical to Input's, so a single-line input and a multi-line textarea can sit in the same form row without visual drift. Only the size variant swaps h-* for min-h-* + py-* — height is a minimum, not a fixed value.
  2. CVA variant × size × autoResize matrix. Three surfaces × three padding tiers × two resize modes covers the canonical textarea surface. Variants are type-safe via class-variance-authority, so a typo at the call site is a compile error.
  3. autoResize uses modern CSS, not JavaScript. When autoResize is true, the component applies field-sizing: content (Chrome 123+, Safari 18+, Firefox 130+) so the browser sizes the textarea to its content natively. No ResizeObserver, no shadow <div>, no measurement effect. Manual resize is disabled in this mode so the user does not fight the auto-size. When false (the default), the native vertical resize handle is enabled.
  4. Focus + invalid states are token-driven. Focus-visible flips the border to --cb-accent and adds a two-ring halo in --cb-accent-muted. aria-invalid="true" flips the same edges to --cb-error with a softer error halo — so a parent Field.Root toggling invalid repaints the textarea without any prop drilling.
  5. Token-only styling. Border (--cb-border), surface (--cb-bg-elevated / --cb-bg-muted), placeholder (--cb-fg-subtle), text (--cb-fg), focus halo (--cb-accent / --cb-accent-muted), and invalid edge (--cb-error) all read from the --cb-* token system. Theme swaps and dark mode repaint without a prop change.

Props

Textarea extends <textarea> HTML attributes (with the native size attribute dropped in favour of the CVA size variant). Additional props:

PropTypeDefaultDescription
variant"outline" | "filled" | "ghost""outline"Visual surface — outline for forms, filled for dense rows, ghost for chrome-inherited containers.
size"sm" | "md" | "lg""md"Padding + minimum-height tier. Maps to the canonical sm / md / lg scale used by Input and Button.
autoResizebooleanfalseWhen true, applies CSS field-sizing: content so the textarea grows with content and disables manual resize.
classNamestringMerged onto the underlying <textarea> via cn() so user classes win on conflict.

Every other native <textarea> attribute (value, defaultValue, onChange, placeholder, name, disabled, readOnly, required, rows, cols, maxLength, minLength, wrap, autoComplete, …) is spread onto the textarea.

Accessibility

  • The component renders a native <textarea>, so screen readers announce the role and value for free.
  • Focus is visible via a token-driven two-ring halo (--cb-accent border + --cb-accent-muted ring); the :focus-visible selector keeps the halo off pointer focus to avoid the mouse-click highlight.
  • aria-invalid="true" flips the border to --cb-error and dims the focus halo to a softer error-tone — pair with Field.Root invalid for the canonical error path, or set the attribute directly when wiring without Field.
  • disabled removes pointer + keyboard reachability (native <textarea disabled> semantics) and fades the surface to 50% opacity for visual contrast.
  • Pair with Field.Label (or any <label htmlFor>) for an accessible name — the bare Textarea has no implicit label, by design.
  • autoResize mode disables manual resize so the user does not fight the auto-size; browsers that do not yet support field-sizing: content fall back to the native auto-sizing behaviour driven by rows.

Credits

  • Extracted from: algoflashcards (src/platform/ui/textarea.tsx). The source was a single-variant <textarea> wrapper hard-coded to rounded-xl bg-input/50 [field-sizing:content] min-h-[80px] with the auto-size behaviour always on and no variant axis. craft-bits broadens it into a CVA variant × size matrix on --cb-* tokens, makes autoResize an opt-in switch with a manual-resize fallback, wraps it in forwardRef, drops the native size attribute from the prop surface, and standardises the focus + invalid state on the same --cb-accent / --cb-error cascade Input and Field consume — so Textarea slots into Field.Root without any glue.