A styled native HTML textarea element. It grows with its content (field-sizing: content) and has built-in styles
for focus, disabled and invalid (aria-invalid) states. Icons, loading indicators and action buttons are composed
with InputGroup and InputGroupTextarea.
Features
Plain native textarea — all HTML textarea attributes are supported
Auto-grows with content via field-sizing-content (minimum height min-h-16)
Focus ring, disabled and aria-invalid error styling out of the box
Dark mode support
Compose icons, text and buttons with InputGroup
Usage
import { Textarea } from '@/components/ui/textarea'import { Label } from '@/components/ui/label'// Basic usage<Textarea placeholder="Describe your question or request…" />// With label<div className="grid w-full gap-1.5"> <Label htmlFor="question">Your question</Label> <Textarea id="question" placeholder="Describe your question or request…" /></div>// Invalid state<Textarea aria-invalid />// Disabled state<Textarea disabled placeholder="Disabled textarea" />
With an icon or buttons, use InputGroup:
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from '@/components/ui/input-group'<InputGroup> <InputGroupTextarea placeholder="Write a message to your case handler…" /> <InputGroupAddon align="block-end"> <InputGroupButton size="icon-xs" aria-label="Attach a file"> <PaperclipIcon /> </InputGroupButton> </InputGroupAddon></InputGroup>
Examples
Default
A plain textarea. It grows with its content, so there is no need to set rows.
With Label
Inside a Field with a FieldLabel and a FieldDescription with guidance.
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'import { Textarea } from '@/components/ui/textarea'export function TextareaWithLabel() { return ( <Field className="w-full max-w-sm"> <FieldLabel htmlFor="textarea-planned-work">Describe the planned work</FieldLabel> <Textarea id="textarea-planned-work" placeholder="For example: a 25 m² extension on the south side of the house, with a flat roof." /> <FieldDescription>Include size, placement and materials. Drawings are attached in the next step.</FieldDescription> </Field> )}
Composer
A secure message to a case handler, composed with InputGroup: a header addon, an attach button, and a send button that
stays disabled until there is text.
import { ArrowUpIcon, LockIcon, PaperclipIcon } from 'lucide-react'import * as React from 'react'import { Avatar, AvatarFallback } from '@/components/ui/avatar'import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupTextarea,} from '@/components/ui/input-group'export function TextareaComposer() { const [message, setMessage] = React.useState('') return ( <InputGroup className="w-full max-w-md"> <InputGroupAddon align="block-start" className="border-b"> <Avatar size="sm"> <AvatarFallback>SB</AvatarFallback> </Avatar> <InputGroupText className="text-foreground">Sofia Berg</InputGroupText> <InputGroupText className="font-normal">· Building permits · Case 2026-04812</InputGroupText> </InputGroupAddon> <InputGroupTextarea aria-label="Message to your case handler" placeholder="Write a message to your case handler…" value={message} onChange={(e) => setMessage(e.target.value)} /> <InputGroupAddon align="block-end"> <InputGroupButton size="icon-xs" variant="outline" aria-label="Attach a file"> <PaperclipIcon /> </InputGroupButton> <InputGroupText className="ml-auto font-normal"> <LockIcon className="size-3" /> Sent securely </InputGroupText> <InputGroupButton size="icon-xs" variant="default" aria-label="Send message" disabled={!message.trim()}> <ArrowUpIcon /> </InputGroupButton> </InputGroupAddon> </InputGroup> )}
Disabled
import { Field, FieldLabel } from '@/components/ui/field'import { Textarea } from '@/components/ui/textarea'export function TextareaDisabled() { return ( <Field className="w-full max-w-sm" data-disabled="true"> <FieldLabel htmlFor="textarea-disabled">Additional information</FieldLabel> <Textarea id="textarea-disabled" disabled placeholder="The application was submitted on 12 March 2026 and can no longer be changed." /> </Field> )}
Read Only
readOnly shows a dashed border and a muted background while keeping the text selectable – useful for decision texts.
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'import { Textarea } from '@/components/ui/textarea'export function TextareaReadOnly() { return ( <Field className="w-full max-w-md"> <FieldLabel htmlFor="textarea-decision">Reasons for the decision</FieldLabel> <Textarea id="textarea-decision" readOnly defaultValue={`The application concerns a garage of 38 m² at Kungsgatan 3. The building is placed 2.1 metres from the property boundary, which is permitted under the detailed plan. The neighbours at Kungsgatan 1 and 5 have been consulted and have no objections. The permit is therefore granted.`} /> <FieldDescription>Decided by Mikko Virtanen on 18 March 2026.</FieldDescription> </Field> )}
Invalid
Set aria-invalid and point aria-describedby at a FieldError.
import { Field, FieldError, FieldLabel } from '@/components/ui/field'import { Textarea } from '@/components/ui/textarea'export function TextareaInvalid() { return ( <Field className="w-full max-w-sm" data-invalid="true"> <FieldLabel htmlFor="textarea-invalid">Reason for appeal</FieldLabel> <Textarea id="textarea-invalid" aria-invalid aria-describedby="textarea-invalid-error" defaultValue="I disagree." /> <FieldError id="textarea-invalid-error">Explain which part of the decision you want changed and why.</FieldError> </Field> )}
Loading
A Spinner and status text in a block-end addon while content is fetched.
import { Field, FieldLabel } from '@/components/ui/field'import { InputGroup, InputGroupAddon, InputGroupText, InputGroupTextarea,} from '@/components/ui/input-group'import { Spinner } from '@/components/ui/spinner'export function TextareaLoading() { return ( <Field className="w-full max-w-md"> <FieldLabel htmlFor="textarea-loading">Previous statement</FieldLabel> <InputGroup data-disabled="true"> <InputGroupTextarea id="textarea-loading" disabled placeholder="Fetching the statement from the archive…" /> <InputGroupAddon align="block-end"> <Spinner /> <InputGroupText className="font-normal">Retrieving document 3 of 4</InputGroupText> </InputGroupAddon> </InputGroup> </Field> )}
With Max Length
A character counter below the field, linked with aria-describedby.
import * as React from 'react'import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'import { Textarea } from '@/components/ui/textarea'export function TextareaWithMaxLength() { const [value, setValue] = React.useState('') const maxLength = 300 return ( <Field className="w-full max-w-sm"> <FieldLabel htmlFor="textarea-max-length">Message to the preschool</FieldLabel> <Textarea id="textarea-max-length" placeholder="Allergies, pick-up arrangements or anything else the staff should know." value={value} onChange={(e) => setValue(e.target.value)} maxLength={maxLength} aria-describedby="textarea-max-length-count" /> <FieldDescription id="textarea-max-length-count" className="text-right tabular-nums"> {value.length} / {maxLength} </FieldDescription> </Field> )}
With Validation
Validates when the field loses focus and shows the error with FieldError.
import * as React from 'react'import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'import { Textarea } from '@/components/ui/textarea'export function TextareaWithValidation() { const [value, setValue] = React.useState('') const [touched, setTouched] = React.useState(false) const error = touched && value.trim().length < 20 ? 'Describe the documents in at least 20 characters.' : '' return ( <Field className="w-full max-w-sm" data-invalid={error ? 'true' : undefined}> <FieldLabel htmlFor="textarea-validation">Which documents are you requesting?</FieldLabel> <FieldDescription>Public records request to the municipal registry.</FieldDescription> <Textarea id="textarea-validation" placeholder="For example: all correspondence about the new sports hall in Ekdalen, January–March 2026." value={value} onChange={(e) => setValue(e.target.value)} onBlur={() => setTouched(true)} aria-invalid={error ? true : undefined} aria-describedby={error ? 'textarea-validation-error' : undefined} /> {error ? <FieldError id="textarea-validation-error">{error}</FieldError> : null} </Field> )}
Props
Textarea accepts all native textarea props (React.ComponentProps<'textarea'>), including className. It has no
custom props.
Accessibility
Uses the native HTML textarea element
Pair with a Label via htmlFor / id
Use aria-invalid and aria-describedby to announce errors
Clear focus indicators and keyboard support
Best Practices
Always use a label; placeholders are supplementary only.
Show a character counter when using maxLength.
Mark invalid fields with aria-invalid and show a clear error message.