Textarea
A multi-line text field that grows with what is typed into it.
Vibra puts the field on the card plane with its --input boundary in both modes, focuses it with the kit's solid 2px accent outline instead of a translucent ring, and goes to the muted plane when disabled — the same treatment as Input. It sizes to its content (field-sizing) from a 64px floor.
Install
npx shadcn@latest add @vibra/textareaNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
export default function TextareaDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="incident-note">Note for the incident timeline</Label>
<Textarea
id="incident-note"
placeholder="What changed, and what you did about it"
aria-describedby="incident-note-hint"
/>
<p id="incident-note-hint" className="text-xs text-muted-foreground">
Visible to everyone on the Engineering team.
</p>
</div>
)
}Grows, then scrolls
It grows with its text up to about six lines, then scrolls inside itself instead of pushing the page down.
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
const MINUTES = `Attendees: Maren Kovač, Tomás Rivera, Aisha Adeyemi.
1. Q3 revenue closed at $1.28M, 6% over plan.
2. Churn held at 2.1%; two enterprise renewals slip to October.
3. Blue Harbor Logistics wants SSO before they add 40 seats.
4. Hiring: one support lead in Dublin, one data engineer remote.
5. Next review on 2 October.`
// The field grows with what is typed, as every Textarea does, up to about six
// lines — then it scrolls inside itself instead of pushing the page down.
export default function TextareaCapped() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="textarea-capped-minutes">Minutes</Label>
<Textarea id="textarea-capped-minutes" defaultValue={MINUTES} className="max-h-36" />
</div>
)
}Count inside the field
The count sits on the field's lower edge. Going over is allowed and said in words; a screen reader hears it when typing pauses.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useDebouncedValue } from "@/hooks/use-debounced-value"
import { InputGroup, InputGroupAddon, InputGroupText, InputGroupTextarea } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
const LIMIT = 280
// The count lives inside the field's lower edge, where the eye already is when
// the text runs long. Going over is allowed and said in words; a screen reader
// hears the count once typing pauses.
export default function TextareaCount() {
const [note, setNote] = React.useState(
"Refunded the duplicate charge on INV-2042. It reaches your card in 5 to 10 working days."
)
const left = LIMIT - note.length
const count = left < 0 ? `${-left} ${left === -1 ? "character" : "characters"} too many` : `${left} characters left`
const spoken = useDebouncedValue(count, 700)
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="textarea-count-note">Note to the customer</Label>
<InputGroup>
<InputGroupTextarea
id="textarea-count-note"
value={note}
aria-invalid={left < 0 || undefined}
aria-describedby="textarea-count-note-count"
onChange={(event) => setNote(event.target.value)}
/>
<InputGroupAddon align="block-end">
<InputGroupText
id="textarea-count-note-count"
className={cn("ms-auto text-xs tabular-nums", left < 0 && "text-destructive")}
>
{count}
</InputGroupText>
</InputGroupAddon>
</InputGroup>
<p className="sr-only" aria-live="polite">
{spoken}
</p>
</div>
)
}Required, with an error
"Required" is a word in the label. Sending too little moves the focus to the field, which carries aria-invalid and the reason.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
const MINIMUM = 20
// "Required" is a word in the label, not a red star. Sending without enough
// moves the focus to the field, which carries aria-invalid and the reason.
export default function TextareaRequired() {
const field = React.useRef<HTMLTextAreaElement>(null)
const [reason, setReason] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
const [sent, setSent] = React.useState(false)
function send(event: React.FormEvent) {
event.preventDefault()
if (reason.trim().length < MINIMUM) {
setError(`Say why in a sentence — at least ${MINIMUM} characters. Blue Harbor Logistics will see it.`)
setSent(false)
field.current?.focus()
return
}
setError(null)
setSent(true)
}
return (
<form noValidate onSubmit={send} className="flex w-full max-w-sm flex-col gap-3">
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="textarea-required-reason">Reason for the refund (required)</FieldLabel>
<Textarea
ref={field}
id="textarea-required-reason"
required
value={reason}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "textarea-required-reason-hint textarea-required-reason-error" : "textarea-required-reason-hint"}
onChange={(event) => {
setReason(event.target.value)
if (error && event.target.value.trim().length >= MINIMUM) setError(null)
}}
/>
<FieldDescription id="textarea-required-reason-hint">Sent with the refund receipt.</FieldDescription>
<FieldError id="textarea-required-reason-error">{error}</FieldError>
</Field>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit">Refund $1,280.00</Button>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{sent ? "Refund on its way to Blue Harbor Logistics." : null}
</p>
</div>
</form>
)
}Read-only, with a reason
A sent invoice's memo: readable, selectable and in the Tab order, with a note saying why it is locked and what to do instead.
import { CopyPlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
// A sent invoice's memo can be read, selected and copied, never changed — so
// it is read-only, not disabled, and the note under it says what to do instead.
export default function TextareaReadonly() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="textarea-readonly-memo">Memo on INV-2042</Label>
<Textarea
id="textarea-readonly-memo"
readOnly
defaultValue="Thank you for your business. Payment terms: net 30. Bank transfer reference NW-2042."
aria-describedby="textarea-readonly-memo-reason"
className="bg-surface"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<p id="textarea-readonly-memo-reason" className="text-sm text-muted-foreground">
Sent on 12 September, so the memo is locked.
</p>
<Button variant="outline" size="sm">
<CopyPlusIcon data-icon="inline-start" aria-hidden="true" />
Duplicate to edit
</Button>
</div>
</div>
)
}One entry per line
A list typed one per line in the code face, with spelling checks off, a count, and a bad line named by its number.
"use client"
import * as React from "react"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
const CIDR = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}\/(3[0-2]|[12]?\d)$/
// One entry per line, in the code face with spelling checks off. The count
// says how many entries the list holds; a bad line is named by its number.
export default function TextareaLines() {
const [text, setText] = React.useState("203.0.113.0/24\n198.51.100.14/32\n10.20.0.0/33")
const lines = text.split("\n").map((line) => line.trim())
const entries = lines.filter(Boolean).length
const bad = lines.findIndex((line) => line && !CIDR.test(line))
const error = bad >= 0 ? `Line ${bad + 1}: ${lines[bad]} isn't an IP range. Ranges end in /0 to /32.` : null
return (
<Field className="w-full max-w-md" data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="textarea-lines-ranges">Allowed IP ranges</FieldLabel>
<Textarea
id="textarea-lines-ranges"
value={text}
rows={4}
wrap="off"
spellCheck={false}
autoCapitalize="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? "textarea-lines-ranges-hint textarea-lines-ranges-error" : "textarea-lines-ranges-hint"}
onChange={(event) => setText(event.target.value)}
className="font-mono"
/>
<FieldDescription id="textarea-lines-ranges-hint">
One range per line. {entries} {entries === 1 ? "range" : "ranges"}; sign-ins from anywhere else are refused.
</FieldDescription>
<FieldError id="textarea-lines-ranges-error">{error}</FieldError>
</Field>
)
}Insertable fields
A template's fields go in at the caret, and the focus comes back with the caret after what was inserted.
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
const FIELDS = [
{ token: "{customer_name}", label: "Customer name" },
{ token: "{invoice_number}", label: "Invoice number" },
{ token: "{amount_due}", label: "Amount due" },
]
// A template with fields filled in at send time. Each button puts its field
// where the caret was and hands the focus back with the caret after it.
export default function TextareaFields() {
const field = React.useRef<HTMLTextAreaElement>(null)
const [body, setBody] = React.useState("Hi {customer_name}, a friendly reminder that {invoice_number} is now due.")
function insert(token: string) {
const textarea = field.current
if (!textarea) return
const { selectionStart: start, selectionEnd: end } = textarea
const next = body.slice(0, start) + token + body.slice(end)
setBody(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.setSelectionRange(start + token.length, start + token.length)
})
}
return (
<div className="flex w-full max-w-md flex-col gap-2">
<Label htmlFor="textarea-fields-reminder">Payment reminder</Label>
<Textarea
ref={field}
id="textarea-fields-reminder"
value={body}
spellCheck
aria-describedby="textarea-fields-reminder-hint"
onChange={(event) => setBody(event.target.value)}
/>
<div role="group" aria-labelledby="textarea-fields-reminder-hint" className="flex flex-wrap items-center gap-1.5">
<span id="textarea-fields-reminder-hint" className="text-sm text-muted-foreground">
Insert a field:
</span>
{FIELDS.map((item) => (
<Button key={item.token} type="button" variant="outline" size="xs" onClick={() => insert(item.token)}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
{item.label}
</Button>
))}
</div>
</div>
)
}With a toolbar
Named tools on the field's lower edge, inside one boundary, with the author's face beside it. Enter starts a line; the button posts.
"use client"
import * as React from "react"
import { AtSignIcon, PaperclipIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"
const AUTHOR = "Maren Kovač"
// A note box with its tools on its lower edge, inside the one field boundary.
// The icon buttons are named; the face says who is writing, and Enter still
// starts a new line — only the button posts.
export default function TextareaToolbar() {
const field = React.useRef<HTMLTextAreaElement>(null)
const [note, setNote] = React.useState("")
const [posted, setPosted] = React.useState<string | null>(null)
function mention() {
setNote((current) => `${current}${current && !current.endsWith(" ") ? " " : ""}@`)
field.current?.focus()
}
return (
<form
className="flex w-full max-w-md items-start gap-3"
onSubmit={(event) => {
event.preventDefault()
if (!note.trim()) return field.current?.focus()
setPosted("Note added to Blue Harbor Logistics.")
setNote("")
field.current?.focus()
}}
>
<Avatar aria-hidden="true" className="mt-1">
<AvatarImage src={avatarFor(AUTHOR)} alt="" />
<AvatarFallback>MK</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<InputGroup>
<InputGroupTextarea
ref={field}
aria-label={`Internal note on Blue Harbor Logistics, as ${AUTHOR}`}
placeholder="Only your team sees notes"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
<InputGroupAddon align="block-end" className="gap-1">
<InputGroupButton size="icon-xs" aria-label="Attach a file">
<PaperclipIcon aria-hidden="true" />
</InputGroupButton>
<InputGroupButton size="icon-xs" aria-label="Mention a teammate" onClick={mention}>
<AtSignIcon aria-hidden="true" />
</InputGroupButton>
<InputGroupButton type="submit" variant="default" size="sm" className="ms-auto h-7 px-2.5">
Add note
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{posted}
</p>
</div>
</form>
)
}Saves as you type
No button: the draft saves when typing pauses, and one polite status line says where it stands.
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
type Save = "saved" | "unsaved" | "saving"
// Nothing to press: the draft saves a moment after typing stops, and one quiet
// status line under the field says where it stands — read politely, never
// on every key.
export default function TextareaAutosave() {
const [text, setText] = React.useState("Blue Harbor wants SSO before adding 40 seats. Loop in Tomás on the security review.")
const [save, setSave] = React.useState<Save>("saved")
React.useEffect(() => {
if (save !== "unsaved") return
const start = setTimeout(() => setSave("saving"), 800)
return () => clearTimeout(start)
}, [text, save])
React.useEffect(() => {
if (save !== "saving") return
const done = setTimeout(() => setSave("saved"), 600)
return () => clearTimeout(done)
}, [save])
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="textarea-autosave-notes">Account notes</Label>
<Textarea
id="textarea-autosave-notes"
value={text}
aria-describedby="textarea-autosave-notes-status"
onChange={(event) => {
setText(event.target.value)
setSave("unsaved")
}}
/>
<p
id="textarea-autosave-notes-status"
role="status"
aria-live="polite"
className="flex min-h-5 items-center gap-1.5 text-sm text-muted-foreground"
>
{save === "saving" ? <Spinner aria-hidden="true" role={undefined} aria-label={undefined} className="size-3.5" /> : null}
{save === "saved" ? <CheckIcon aria-hidden="true" className="size-3.5 text-success" /> : null}
{save === "saving" ? "Saving…" : save === "saved" ? "Saved" : "Unsaved changes"}
</p>
</div>
)
}Right to left
An Arabic reply with its count at the inline end of the field's lower edge.
"use client"
import * as React from "react"
import { InputGroup, InputGroupAddon, InputGroupText, InputGroupTextarea } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
const LIMIT = 200
// An Arabic reply box: the label and the text start on the right, and the count
// sits at the inline end of the field's lower edge — the left.
export default function TextareaRtl() {
const [reply, setReply] = React.useState("شكرًا لتواصلكم. أصدرنا فاتورة مصححة وأرسلناها إلى بريدكم.")
const left = LIMIT - reply.length
return (
<div dir="rtl" lang="ar" className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="textarea-rtl-reply">الرد على العميل</Label>
<InputGroup>
<InputGroupTextarea
id="textarea-rtl-reply"
value={reply}
aria-describedby="textarea-rtl-reply-count"
onChange={(event) => setReply(event.target.value)}
/>
<InputGroupAddon align="block-end">
<InputGroupText id="textarea-rtl-reply-count" className="ms-auto text-xs tabular-nums">
{`الأحرف المتبقية: ${left}`}
</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
)
}Dependencies
Registry
Source
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-card px-2.5 py-2 text-base transition-colors focus-ring placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }