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.
Installation
npx shadcn@latest add https://craftbits.dev/r/textarea.jsonUsage
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
- Matched to
Input. The variant matrix (outline/filled/ghost) and size scale (sm/md/lg) are byte-for-byte identical toInput'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 swapsh-*formin-h-*+py-*— height is a minimum, not a fixed value. - 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. autoResizeuses modern CSS, not JavaScript. WhenautoResizeistrue, the component appliesfield-sizing: content(Chrome 123+, Safari 18+, Firefox 130+) so the browser sizes the textarea to its content natively. NoResizeObserver, no shadow<div>, no measurement effect. Manual resize is disabled in this mode so the user does not fight the auto-size. Whenfalse(the default), the native vertical resize handle is enabled.- Focus + invalid states are token-driven. Focus-visible flips the border to
--cb-accentand adds a two-ring halo in--cb-accent-muted.aria-invalid="true"flips the same edges to--cb-errorwith a softer error halo — so a parentField.Roottogglinginvalidrepaints the textarea without any prop drilling. - 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:
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
autoResize | boolean | false | When true, applies CSS field-sizing: content so the textarea grows with content and disables manual resize. |
className | string | — | Merged 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-accentborder +--cb-accent-mutedring); the:focus-visibleselector keeps the halo off pointer focus to avoid the mouse-click highlight. aria-invalid="true"flips the border to--cb-errorand dims the focus halo to a softer error-tone — pair withField.Root invalidfor the canonical error path, or set the attribute directly when wiring withoutField.disabledremoves 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 bareTextareahas no implicit label, by design. autoResizemode disables manual resize so the user does not fight the auto-size; browsers that do not yet supportfield-sizing: contentfall back to the native auto-sizing behaviour driven byrows.
Credits
- Extracted from:
algoflashcards(src/platform/ui/textarea.tsx). The source was a single-variant<textarea>wrapper hard-coded torounded-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, makesautoResizean opt-in switch with a manual-resize fallback, wraps it inforwardRef, drops the nativesizeattribute from the prop surface, and standardises the focus + invalid state on the same--cb-accent/--cb-errorcascadeInputandFieldconsume — soTextareaslots intoField.Rootwithout any glue.