Textarea
React formMulti-line text input. Auto-resize variant, character counter, and the same ring/border treatment as the rest of the form primitives.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/textarea.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/textarea.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/textarea.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/textarea.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/textarea
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
value Core | string | number | — | optional |
defaultValue | string | number | — | optional |
onValueChange | (value: string) => void | — | optional |
label | string | — | optional |
placeholder | string | — | optional |
hint | string | — | optional |
error | string | — | optional |
success | string | — | optional |
messages | string[] | — | optional |
disabled | boolean | — | optional |
readOnly | boolean | — | optional |
required | boolean | — | optional |
autoFocus | boolean | — | optional |
name | string | — | optional |
id | string | — | optional |
variant Variants (Vuetify-style) | 'outlined' | 'filled' | 'solo' | 'underlined' | 'plain' | — | optional |
color | 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success' | — | optional |
density | 'compact' | 'comfortable' | 'default' | — | optional |
rounded Appearance | 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'pill' | 'circle' | 'full' | — | optional |
autoSize Auto size (Ant Design API) | boolean | { minRows?: number; maxRows?: number } | — | optional |
autoGrow Legacy auto grow / resize | boolean | — | optional |
noResize | boolean | — | optional |
autoResize | boolean | — | optional |
rows Rows -- accept both number and string ("3" vs rows={3}). | number | string | — | optional |
rowHeight | number | — | optional |
prefix Prefix/Suffix | string | — | optional |
suffix | string | — | optional |
counter Counter (legacy) | boolean | number | — | optional |
showCount Show count (Ant Design API) | boolean | { formatter?: (count: number, maxLength?: number) => string } | — | optional |
maxLength Max length | number | — | optional |
allowClear Allow clear | boolean | — | optional |
rules Validation | Array<(value: any) => true | string> | — | optional |
errorMessages | string | string[] | — | optional |
successMessages | string | string[] | — | optional |
validateOn | 'blur' | 'input' | 'submit' | 'lazy' | 'blurlazy' | 'inputlazy' | — | optional |
loading States | boolean | — | optional |
persistentHint | boolean | — | optional |
persistentError | boolean | — | optional |
persistentPlaceholder | boolean | — | optional |
persistentPrefix | boolean | — | optional |
persistentSuffix | boolean | — | optional |
className Misc | string | — | optional |
inputClassName | string | — | optional |
labelClassName | string | — | optional |
hintClassName | string | — | optional |
bgColor | string | — | optional |
flat | boolean | — | optional |
bordered | boolean | — | optional |
spellCheck Browser | boolean | — | optional |
autoComplete | string | — | optional |
direction Direction | 'ltr' | 'rtl' | — | optional |
onClear Events | () => void | — | optional |
onFocus | () => void | — | optional |
onBlur | () => void | — | optional |
onKeyDown | () => void | — | optional |
onKeyUp | () => void | — | optional |
children | React.ReactNode | — | optional |
Dependencies
Used by
Files (2)
-
components/ui/textarea/Textarea.tsx 16.7 kB
'use client' import * as React from 'react' import { Loader, Check, AlertCircle, X } from 'lucide-react' import { cn } from '@/lib/utils' import { Label } from '@/components/ui/label' export interface TextareaProps { // Core value?: string | number defaultValue?: string | number onValueChange?: (value: string) => void label?: string placeholder?: string hint?: string error?: string success?: string messages?: string[] disabled?: boolean readOnly?: boolean required?: boolean autoFocus?: boolean name?: string id?: string // Variants (Vuetify-style) variant?: 'outlined' | 'filled' | 'solo' | 'underlined' | 'plain' color?: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success' density?: 'compact' | 'comfortable' | 'default' // Appearance rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'pill' | 'circle' | 'full' // Auto size (Ant Design API) autoSize?: boolean | { minRows?: number; maxRows?: number } // Legacy auto grow / resize autoGrow?: boolean noResize?: boolean autoResize?: boolean // Rows -- accept both number and string ("3" vs rows={3}). rows?: number | string rowHeight?: number // Prefix/Suffix prefix?: string suffix?: string // Counter (legacy) counter?: boolean | number // Show count (Ant Design API) showCount?: boolean | { formatter?: (count: number, maxLength?: number) => string } // Max length maxLength?: number // Allow clear allowClear?: boolean // Validation rules?: Array<(value: any) => true | string> errorMessages?: string | string[] successMessages?: string | string[] validateOn?: 'blur' | 'input' | 'submit' | 'lazy' | 'blurlazy' | 'inputlazy' // States loading?: boolean persistentHint?: boolean persistentError?: boolean persistentPlaceholder?: boolean persistentPrefix?: boolean persistentSuffix?: boolean // Misc className?: string inputClassName?: string labelClassName?: string hintClassName?: string bgColor?: string flat?: boolean bordered?: boolean // Browser spellCheck?: boolean autoComplete?: string // Direction direction?: 'ltr' | 'rtl' // Events onClear?: () => void onFocus?: () => void onBlur?: () => void onKeyDown?: () => void onKeyUp?: () => void children?: React.ReactNode } const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>((props, forwardedRef) => { const { value, defaultValue, onValueChange, label, placeholder, hint, error, success, disabled, readOnly, required, autoFocus, name, id, variant = 'outlined', density = 'default', rounded = 'none', autoSize, autoGrow = false, noResize = false, autoResize = false, rows = 3, rowHeight = 24, prefix, suffix, counter, showCount, maxLength, allowClear, rules, errorMessages, successMessages, validateOn, loading, persistentHint = false, persistentPrefix, persistentSuffix, className, inputClassName, labelClassName, hintClassName, bgColor, spellCheck, autoComplete, onClear, onFocus, onBlur, onKeyDown, onKeyUp, children, } = props const reactId = React.useId() const textareaId = id ?? `textarea-${reactId}` const descriptionId = `${textareaId}-description` const textareaRef = React.useRef<HTMLTextAreaElement | null>(null) const setRefs = React.useCallback( (node: HTMLTextAreaElement | null) => { textareaRef.current = node if (typeof forwardedRef === 'function') forwardedRef(node) else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node }, [forwardedRef], ) // Internal state const isControlled = value !== undefined const [internal, setInternal] = React.useState<string | number>(defaultValue ?? '') const internalValue = isControlled ? value! : internal const [focused, setFocused] = React.useState(false) const [internalErrorMessages, setInternalErrorMessages] = React.useState<string[]>([]) const [validated] = React.useState(false) // Auto size const autoSizeEnabled = autoSize !== undefined const anyAutoResize = autoSizeEnabled || autoResize || autoGrow const autoSizeConfig = React.useMemo<{ minRows?: number; maxRows?: number }>(() => { if (typeof autoSize === 'object') return autoSize return {} }, [autoSize]) const minHeightPx = React.useRef(0) const maxHeightPx = React.useRef(Infinity) const autoResizeFn = React.useCallback(() => { if (!textareaRef.current) return if (!anyAutoResize) return const el = textareaRef.current el.style.height = 'auto' let newHeight = el.scrollHeight if (minHeightPx.current && newHeight < minHeightPx.current) { newHeight = minHeightPx.current } if (newHeight > maxHeightPx.current) { newHeight = maxHeightPx.current el.style.overflowY = 'auto' } else { el.style.overflowY = 'hidden' } el.style.height = `${newHeight}px` }, [anyAutoResize]) const measureHeights = React.useCallback(() => { if (!textareaRef.current) return if (!autoSizeEnabled) return const el = textareaRef.current const originalValue = el.value const originalRows = el.rows const originalOverflow = el.style.overflowY el.value = '' el.style.overflowY = 'hidden' const { minRows, maxRows } = autoSizeConfig if (minRows) { el.rows = minRows minHeightPx.current = el.scrollHeight } else { minHeightPx.current = 0 } if (maxRows) { el.rows = maxRows maxHeightPx.current = el.scrollHeight } else { maxHeightPx.current = Infinity } el.value = originalValue el.rows = originalRows el.style.overflowY = originalOverflow autoResizeFn() }, [autoSizeEnabled, autoSizeConfig, autoResizeFn]) // Auto grow functionality (legacy) const rowsNum = Number(rows) || 3 const computedRows = React.useMemo(() => { if (autoSizeEnabled || autoResize) return rowsNum if (!autoGrow) return rowsNum if (!textareaRef.current) return rowsNum const lineHeight = rowHeight const computedHeight = textareaRef.current.scrollHeight const newRows = Math.ceil((computedHeight - lineHeight) / lineHeight) + 1 return Math.max(rowsNum, newRows) }, [autoSizeEnabled, autoResize, autoGrow, rowsNum, rowHeight, internalValue]) // Validation const validate = React.useCallback(() => { if (!rules || rules.length === 0) return true const next: string[] = [] for (const rule of rules) { const result = rule(internalValue) if (result !== true) { next.push(result as string) } } setInternalErrorMessages(next) return next.length === 0 }, [rules, internalValue]) // Handle input const setValue = (next: string) => { if (!isControlled) setInternal(next) onValueChange?.(next) } const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { setValue(e.target.value) if (anyAutoResize) { autoResizeFn() } } // Handle clear const handleClear = () => { setValue('') onClear?.() requestAnimationFrame(() => { autoResizeFn() textareaRef.current?.focus() }) } // Handle focus/blur const handleFocus = () => { setFocused(true) onFocus?.() } const handleBlur = () => { setFocused(false) if (validateOn === 'blur' || validateOn === 'blurlazy') { validate() } onBlur?.() } // Compute error/success messages const computedErrorMessages = React.useMemo(() => { if (errorMessages) { return Array.isArray(errorMessages) ? errorMessages : [errorMessages] } if (error) { return [error] } return internalErrorMessages }, [errorMessages, error, internalErrorMessages]) const computedSuccessMessages = React.useMemo(() => { if (successMessages) { return Array.isArray(successMessages) ? successMessages : [successMessages] } if (success) { return [success] } return [] }, [successMessages, success]) const hasError = computedErrorMessages.length > 0 const hasSuccess = computedSuccessMessages.length > 0 && validated // Counter (legacy) const computedCounter = React.useMemo(() => { if (typeof counter === 'number') return counter if (counter) return maxLength ?? 100 return null }, [counter, maxLength]) const currentLength = String(internalValue ?? '').length // Show count (Ant Design API) const showCountEnabled = showCount !== undefined && showCount !== false const showCountConfig = React.useMemo<{ formatter?: (count: number, maxLength?: number) => string }>(() => { if (typeof showCount === 'object') return showCount return {} }, [showCount]) const countText = React.useMemo(() => { const formatter = showCountConfig.formatter if (formatter) { return formatter(currentLength, maxLength) } if (maxLength !== undefined) { return `${currentLength} / ${maxLength}` } return `${currentLength}` }, [showCountConfig, currentLength, maxLength]) // Allow clear const showClear = allowClear && !disabled && !readOnly && String(internalValue ?? '').length > 0 // Variant classes const variantClasses = React.useMemo(() => { const base = 'w-full transition-colors duration-200' switch (variant) { case 'outlined': return cn( base, 'border-2 rounded-lg', focused ? 'border-primary ring-2 ring-primary/20' : 'border-input', hasError && 'border-destructive focus:border-destructive focus:ring-destructive/20', ) case 'filled': return cn( base, 'border-b-2 bg-muted/50 rounded-t-lg', focused ? 'border-primary bg-muted' : 'border-transparent', hasError && 'border-destructive', ) case 'solo': return cn( base, 'rounded-lg shadow-sm', focused ? 'shadow-md' : 'shadow-sm', 'bg-card border border-transparent', ) case 'underlined': return cn( base, 'border-b-2 rounded-none border-x-0 border-t-0 px-0', focused ? 'border-primary' : 'border-muted-foreground/30', hasError && 'border-destructive', ) case 'plain': return cn(base, 'border-0 bg-transparent') default: return base } }, [variant, focused, hasError]) // Density classes const densityClasses = React.useMemo(() => { switch (density) { case 'compact': return 'text-sm min-h-[32px]' case 'comfortable': return 'text-base min-h-[40px]' case 'default': default: return 'text-base min-h-[48px]' } }, [density]) // Resize classes const resizeClasses = React.useMemo(() => { if (noResize) return 'resize-none' if (anyAutoResize) return 'resize-none' return 'resize-y' }, [noResize, anyAutoResize]) // Watch for programmatic value changes to trigger auto-resize React.useEffect(() => { if (anyAutoResize) { requestAnimationFrame(() => autoResizeFn()) } }, [internalValue, anyAutoResize, autoResizeFn]) React.useEffect(() => { requestAnimationFrame(() => { measureHeights() if (anyAutoResize) { autoResizeFn() } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return ( <div className={cn('relative space-y-2', className)}> {/* Label */} {label && ( <Label htmlFor={textareaId} className={cn( 'text-foreground text-sm font-medium', labelClassName, focused && 'text-primary', hasError && 'text-destructive', )} > {label} {required && <span className="text-destructive ml-0.5">*</span>} </Label> )} {/* Control wrapper */} <div className={cn( 'relative flex items-center', variantClasses, densityClasses, disabled && 'pointer-events-none opacity-50', readOnly && !disabled && 'cursor-default', rounded !== 'none' && `rounded-${rounded}`, )} style={bgColor ? { backgroundColor: bgColor } : undefined} > {/* Prefix */} {prefix && ( <span className={cn( 'text-muted-foreground pointer-events-none absolute top-3 left-3 text-sm', !persistentPrefix && !focused && 'opacity-50', )} > {prefix} </span> )} {/* Textarea */} <textarea id={textareaId} ref={setRefs} value={internalValue} placeholder={placeholder} disabled={disabled} readOnly={readOnly} required={required} name={name} autoComplete={autoComplete} autoFocus={autoFocus} spellCheck={spellCheck} maxLength={maxLength} rows={computedRows} aria-describedby={descriptionId} aria-invalid={hasError} className={cn( 'w-full flex-1 resize-y bg-transparent outline-none', densityClasses, resizeClasses, prefix ? 'pl-16' : 'pl-3', suffix ? 'pr-16' : showClear ? 'pr-10' : 'pr-3', showCountEnabled && 'pb-6', 'py-2', inputClassName, )} onChange={handleInput} onFocus={handleFocus} onBlur={handleBlur} onKeyDown={() => onKeyDown?.()} onKeyUp={() => onKeyUp?.()} /> {/* Suffix */} {suffix && ( <span className={cn( 'text-muted-foreground pointer-events-none absolute top-3 right-3 text-sm', !persistentSuffix && !focused && 'opacity-50', )} > {suffix} </span> )} {/* Clear button */} {showClear && ( <button type="button" tabIndex={-1} className={cn( 'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-3 flex items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none', suffix ? 'right-10' : 'right-3', )} onClick={handleClear} > <X className="size-4" aria-hidden="true" /> </button> )} {/* Loading spinner */} {loading && ( <div className="absolute top-3 right-3 flex items-center justify-center"> <Loader className="text-muted-foreground size-4 animate-spin" /> </div> )} {/* Success/Error indicators */} {hasSuccess && !loading && ( <div className="absolute top-3 right-3 flex items-center justify-center text-[var(--success)]"> <Check className="size-4" aria-hidden="true" /> </div> )} {hasError && !loading && ( <div className="text-destructive absolute top-3 right-3 flex items-center justify-center"> <AlertCircle className="size-4" aria-hidden="true" /> </div> )} {/* Show count */} {showCountEnabled && ( <div className={cn( 'text-muted-foreground pointer-events-none absolute right-3 bottom-1.5 text-xs', maxLength !== undefined && currentLength > maxLength && 'text-destructive', )} > {countText} </div> )} </div> {/* Messages (hint, error, success) */} <div className="mt-1.5"> {/* Hint */} {hint && (!hasError || persistentHint) && !focused && ( <p className={cn('text-muted-foreground text-sm', hintClassName)}>{hint}</p> )} {/* Error messages */} {computedErrorMessages.map((msg, i) => ( <p key={`error-${i}`} className="text-destructive flex items-center gap-1 text-sm"> <AlertCircle className="size-3 shrink-0" aria-hidden="true" /> {msg} </p> ))} {/* Success messages */} {computedSuccessMessages.map((msg, i) => ( <p key={`success-${i}`} className="flex items-center gap-1 text-sm text-[var(--success)]"> <Check className="size-3 shrink-0" /> {msg} </p> ))} {/* Counter (legacy) */} {computedCounter !== null && ( <div className={cn( 'text-muted-foreground mt-1 text-right text-xs', currentLength > computedCounter && 'text-destructive', )} > {currentLength} / {computedCounter} </div> )} </div> {children} </div> ) }) Textarea.displayName = 'Textarea' export { Textarea } -
components/ui/textarea/index.ts 0.1 kB
export { Textarea, type TextareaProps } from './Textarea'
Raw manifest: https://react.uipkge.dev/r/react/textarea.json