Textarea
Vue 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 React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/textarea.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/textarea.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/textarea.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/textarea.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/textarea
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue Core | string | number | — | optional |
defaultValue | string | number | — | 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' | 'outlined' | optional |
color | 'primary''secondary''error''warning''info''success' | — | optional |
density | 'compact''comfortable''default' | 'default' | optional |
rounded Appearance | 'none''sm''md''lg''xl''pill''circle''full' | 'none' | optional |
autoSize Auto size (Ant Design API) | boolean | { minRows?: number; maxRows?: number } | — | optional |
autoGrow Legacy auto grow / resize | boolean | false | optional |
noResize | boolean | false | optional |
autoResize | boolean | false | optional |
rows attribute usage doesn't trip the Vue prop-type warning. | number | string | 3 | optional |
rowHeight | number | 24 | 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 | false | optional |
persistentError | boolean | false | optional |
persistentPlaceholder | boolean | false | optional |
persistentPrefix | boolean | — | optional |
persistentSuffix | boolean | — | optional |
class Misc | HTMLAttributes['class'] | — | optional |
inputClass | HTMLAttributes['class'] | — | optional |
labelClass | HTMLAttributes['class'] | — | optional |
hintClass | HTMLAttributes['class'] | — | optional |
bgColor | string | — | optional |
flat | boolean | false | optional |
bordered | boolean | true | optional |
spellcheck Browser | boolean | — | optional |
autocomplete | string | — | optional |
direction Direction | 'ltr''rtl' | 'ltr' | optional |
Dependencies
Used by
Files (2)
-
app/components/ui/textarea/Textarea.vue 15.1 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { provide, computed, ref, nextTick, watch, onMounted } from 'vue' import { useId } from 'reka-ui' import { useVModel } from '@vueuse/core' import { cn } from '@/lib/utils' import { Loader, Check, AlertCircle, X } from 'lucide-vue-next' import { Label } from '@/components/ui/label' export interface TextareaProps { // Core modelValue?: string | number defaultValue?: string | number 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") so unbound // attribute usage doesn't trip the Vue prop-type warning. 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 class?: HTMLAttributes['class'] inputClass?: HTMLAttributes['class'] labelClass?: HTMLAttributes['class'] hintClass?: HTMLAttributes['class'] bgColor?: string flat?: boolean bordered?: boolean // Browser spellcheck?: boolean autocomplete?: string // Direction direction?: 'ltr' | 'rtl' } const props = withDefaults(defineProps<TextareaProps>(), { variant: 'outlined', density: 'default', rounded: 'none', rows: 3, rowHeight: 24, autoGrow: false, noResize: false, autoResize: false, flat: false, bordered: true, persistentHint: false, persistentError: false, persistentPlaceholder: false, direction: 'ltr', }) const emits = defineEmits<{ (e: 'update:modelValue', payload: string | number): void (e: 'click:clear'): void (e: 'focus'): void (e: 'blur'): void (e: 'keydown'): void (e: 'keyup'): void }>() const textareaId = props.id ?? `textarea-${useId()}` const descriptionId = `${textareaId}-description` const messageId = `${textareaId}-message` provide('form-item', { id: textareaId, descriptionId, messageId, }) // Internal state const internalValue = useVModel(props, 'modelValue', emits, { passive: true, defaultValue: props.defaultValue, }) const focused = ref(false) const internalErrorMessages = ref<string[]>([]) const validated = ref(false) const textareaRef = ref<HTMLTextAreaElement | null>(null) // Auto size const autoSizeEnabled = computed(() => props.autoSize !== undefined) const anyAutoResize = computed(() => autoSizeEnabled.value || props.autoResize || props.autoGrow) const autoSizeConfig = computed<{ minRows?: number; maxRows?: number }>(() => { if (typeof props.autoSize === 'object') { return props.autoSize } return {} }) const minHeightPx = ref(0) const maxHeightPx = ref(Infinity) const measureHeights = () => { if (!textareaRef.value) return if (!autoSizeEnabled.value) return const el = textareaRef.value const originalValue = el.value const originalRows = el.rows const originalOverflow = el.style.overflowY el.value = '' el.style.overflowY = 'hidden' const { minRows, maxRows } = autoSizeConfig.value if (minRows) { el.rows = minRows minHeightPx.value = el.scrollHeight } else { minHeightPx.value = 0 } if (maxRows) { el.rows = maxRows maxHeightPx.value = el.scrollHeight } else { maxHeightPx.value = Infinity } el.value = originalValue el.rows = originalRows el.style.overflowY = originalOverflow autoResize() } watch(() => autoSizeConfig.value, measureHeights, { deep: true }) // Auto grow functionality (legacy) const rowsNum = computed(() => Number(props.rows) || 3) const computedRows = computed(() => { if (autoSizeEnabled.value || props.autoResize) return rowsNum.value if (!props.autoGrow) return rowsNum.value if (!textareaRef.value) return rowsNum.value const lineHeight = props.rowHeight const computedHeight = textareaRef.value.scrollHeight const newRows = Math.ceil((computedHeight - lineHeight) / lineHeight) + 1 return Math.max(rowsNum.value, newRows) }) // Validation const validate = () => { if (!props.rules || props.rules.length === 0) return true internalErrorMessages.value = [] for (const rule of props.rules) { const result = rule(internalValue.value) if (result !== true) { internalErrorMessages.value.push(result as string) } } return internalErrorMessages.value.length === 0 } // Handle input const handleInput = (e: Event) => { const target = e.target as HTMLTextAreaElement internalValue.value = target.value if (anyAutoResize.value) { autoResize() } } const autoResize = () => { if (!textareaRef.value) return if (!anyAutoResize.value) return const el = textareaRef.value el.style.height = 'auto' let newHeight = el.scrollHeight if (minHeightPx.value && newHeight < minHeightPx.value) { newHeight = minHeightPx.value } if (newHeight > maxHeightPx.value) { newHeight = maxHeightPx.value el.style.overflowY = 'auto' } else { el.style.overflowY = 'hidden' } el.style.height = `${newHeight}px` } // Handle clear const handleClear = () => { internalValue.value = '' emits('click:clear') nextTick(() => { autoResize() textareaRef.value?.focus() }) } // Handle focus/blur const handleFocus = () => { focused.value = true emits('focus') } const handleBlur = () => { focused.value = false if (props.validateOn === 'blur' || props.validateOn === 'blurlazy') { validate() } emits('blur') } // Compute error/success messages const computedErrorMessages = computed(() => { if (props.errorMessages) { return Array.isArray(props.errorMessages) ? props.errorMessages : [props.errorMessages] } if (props.error) { return [props.error] } return internalErrorMessages.value }) const computedSuccessMessages = computed(() => { if (props.successMessages) { return Array.isArray(props.successMessages) ? props.successMessages : [props.successMessages] } if (props.success) { return [props.success] } return [] }) const hasError = computed(() => computedErrorMessages.value.length > 0) const hasSuccess = computed(() => computedSuccessMessages.value.length > 0 && validated.value) // Counter (legacy) const computedCounter = computed(() => { if (typeof props.counter === 'number') return props.counter if (props.counter) return props.maxLength ?? 100 return null }) const currentLength = computed(() => String(internalValue.value ?? '').length) // Show count (Ant Design API) const showCountEnabled = computed(() => { return props.showCount !== undefined && props.showCount !== false }) const showCountConfig = computed<{ formatter?: (count: number, maxLength?: number) => string }>(() => { if (typeof props.showCount === 'object') { return props.showCount } return {} }) const countText = computed(() => { const formatter = showCountConfig.value.formatter if (formatter) { return formatter(currentLength.value, props.maxLength) } if (props.maxLength !== undefined) { return `${currentLength.value} / ${props.maxLength}` } return `${currentLength.value}` }) // Allow clear const showClear = computed(() => { return props.allowClear && !props.disabled && !props.readonly && String(internalValue.value ?? '').length > 0 }) // Variant classes const variantClasses = computed(() => { const base = 'w-full transition-colors duration-200' switch (props.variant) { case 'outlined': return cn( base, 'border-2 rounded-lg', focused.value ? 'border-primary ring-2 ring-primary/20' : 'border-input', hasError.value && 'border-destructive focus:border-destructive focus:ring-destructive/20', ) case 'filled': return cn( base, 'border-b-2 bg-muted/50 rounded-t-lg', focused.value ? 'border-primary bg-muted' : 'border-transparent', hasError.value && 'border-destructive', ) case 'solo': return cn( base, 'rounded-lg shadow-sm', focused.value ? '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.value ? 'border-primary' : 'border-muted-foreground/30', hasError.value && 'border-destructive', ) case 'plain': return cn(base, 'border-0 bg-transparent') default: return base } }) // Density classes const densityClasses = computed(() => { switch (props.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]' } }) // Resize classes const resizeClasses = computed(() => { if (props.noResize) return 'resize-none' if (anyAutoResize.value) return 'resize-none' return 'resize-y' }) // Watch for programmatic value changes to trigger auto-resize watch(internalValue, () => { if (anyAutoResize.value) { nextTick(() => autoResize()) } }) onMounted(() => { nextTick(() => { measureHeights() if (anyAutoResize.value) { autoResize() } }) }) </script> <template> <div :class="cn('relative space-y-2', props.class)"> <!-- Label --> <Label v-if="label" :for="textareaId" :class="[ 'text-foreground text-sm font-medium', props.labelClass, focused && 'text-primary', hasError && 'text-destructive', ]" > {{ label }} <span v-if="required" class="text-destructive ml-0.5">*</span> </Label> <!-- Control wrapper --> <div :class=" cn( 'relative flex items-center', variantClasses, densityClasses, disabled && 'pointer-events-none opacity-50', props.readonly && !disabled && 'cursor-default', props.rounded !== 'none' && `rounded-${props.rounded}`, ) " :style="props.bgColor ? { backgroundColor: props.bgColor } : {}" > <!-- Prefix --> <span v-if="prefix" class="text-muted-foreground pointer-events-none absolute top-3 left-3 text-sm" :class="{ 'opacity-50': !persistentPrefix && !focused }" > {{ prefix }} </span> <!-- Textarea --> <textarea :id="textareaId" :ref=" (el) => { textareaRef = el as HTMLTextAreaElement } " :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" :class=" 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', props.inputClass, ) " @input="handleInput" @focus="handleFocus" @blur="handleBlur" @keydown="emits('keydown')" @keyup="emits('keyup')" /> <!-- Suffix --> <span v-if="suffix" class="text-muted-foreground pointer-events-none absolute top-3 right-3 text-sm" :class="{ 'opacity-50': !persistentSuffix && !focused }" > {{ suffix }} </span> <!-- Clear button --> <button v-if="showClear" type="button" tabindex="-1" class="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" :class="suffix ? 'right-10' : 'right-3'" @click="handleClear" > <X class="size-4" aria-hidden="true" /> </button> <!-- Loading spinner --> <div v-if="loading" class="absolute top-3 right-3 flex items-center justify-center"> <Loader class="text-muted-foreground size-4 animate-spin" /> </div> <!-- Success/Error indicators --> <div v-if="hasSuccess && !loading" class="absolute top-3 right-3 flex items-center justify-center text-[var(--success)]" > <Check class="size-4" aria-hidden="true" /> </div> <div v-if="hasError && !loading" class="text-destructive absolute top-3 right-3 flex items-center justify-center"> <AlertCircle class="size-4" aria-hidden="true" /> </div> <!-- Show count --> <div v-if="showCountEnabled" class="text-muted-foreground pointer-events-none absolute right-3 bottom-1.5 text-xs" :class="{ 'text-destructive': props.maxLength !== undefined && currentLength > props.maxLength }" > {{ countText }} </div> </div> <!-- Messages (hint, error, success) --> <div class="mt-1.5"> <!-- Hint --> <p v-if="hint && (!hasError || persistentHint) && !focused" :class="cn('text-muted-foreground text-sm', props.hintClass)" > {{ hint }} </p> <!-- Error messages --> <p v-for="(msg, i) in computedErrorMessages" :key="`error-${i}`" class="text-destructive flex items-center gap-1 text-sm" > <AlertCircle class="size-3 shrink-0" aria-hidden="true" /> {{ msg }} </p> <!-- Success messages --> <p v-for="(msg, i) in computedSuccessMessages" :key="`success-${i}`" class="flex items-center gap-1 text-sm text-[var(--success)]" > <Check class="size-3 shrink-0" /> {{ msg }} </p> <!-- Counter (legacy) --> <div v-if="computedCounter !== null" class="text-muted-foreground mt-1 text-right text-xs" :class="{ 'text-destructive': currentLength > computedCounter }" > {{ currentLength }} / {{ computedCounter }} </div> </div> <slot /> </div> </template> -
app/components/ui/textarea/index.ts 0.1 kB
export { default as Textarea } from './Textarea.vue'
Raw manifest: https://uipkge.dev/r/vue/textarea.json