Textarea
Textarea is a styled native control with one durable behavior: its presentation is derived from the value it currently renders and its responsive width. Restored and controlled values therefore receive the right height without a second persistence layer.
Installation
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add textarea- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
Native form recipe
<script setup>
import Textarea from '@/components/ui/textarea/Textarea.vue'
</script>
<template>
<div class="grid gap-2">
<label for="note">Internal note</label>
<Textarea
id="note"
v-model="form.note"
name="note"
rows="3"
:aria-invalid="Boolean(form.errors.note)"
aria-describedby="note-help note-error"
/>
<p id="note-help">Plain text, up to 2,000 characters.</p>
<p id="note-error" class="empty:hidden text-sm text-red-700">
{{ form.errors.note }}
</p>
</div>
</template>
The surrounding label, help, error, IDs, validation, and value source remain ordinary application markup. Keep help and error nodes stable, bind aria-invalid to the boolean error state, and hide an empty error with empty:hidden. There is no Field context, accessibility helper, or autoGrow switch.
Durable resizing
Textarea measures after mount and after its current value changes. It also observes width changes because responsive wrapping changes content height. This covers server data, URL state, and application-owned restored drafts without writing localStorage itself.
Content-derived height is the default contract, not a feature flag. Caller Tailwind can still replace it:
<Textarea class="h-40 resize-y overflow-y-auto" />Because caller classes merge last, this removes the derived height, hidden overflow, and fixed-resize defaults cleanly.
API
Textarea accepts native textarea attributes, framework-native value binding, and caller classes. It exposes its native element for explicit focus recovery. It has no variant, size, autoGrow, label, description, error, or validation props.
Accessibility contract
- Use a real associated label and connect help or error text explicitly.
- Keep description IDs stable instead of rebuilding them when an error changes.
- Keep the native
name,required,disabled, and form behavior. - Apply
aria-invalidwith useful visible error text. - Do not use placeholder text as the label.
- Caller-owned fixed sizing must preserve usable content access and keyboard operation.
- Focus remains visible and decorative transitions respect reduced motion.
Complete framework source
The live preview demonstrates the shared native and content-derived sizing contract. Copy the complete framework-native source for your application:
<script setup>
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
watch
} from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
modelValue: { type: [String, Number], default: undefined }
})
const emit = defineEmits(['update:modelValue'])
const attrs = useAttrs()
const element = ref()
let composing = false
let resizeObserver
const resolvedValue = computed(() => props.modelValue ?? attrs.value)
const forwardedAttrs = computed(() => {
const {
class: _class,
value: _value,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
function updateValue(event) {
if (composing) return
resizeToContent()
emit('update:modelValue', event.target.value)
}
function finishComposition(event) {
composing = false
updateValue(event)
}
function resizeToContent() {
if (!element.value) return
element.value.style.removeProperty('--klean-textarea-height')
element.value.style.setProperty(
'--klean-textarea-height',
`${element.value.scrollHeight}px`
)
}
onMounted(() => {
resizeToContent()
if (typeof ResizeObserver === 'undefined') return
let width = element.value.offsetWidth
resizeObserver = new ResizeObserver(([entry]) => {
if (entry.contentRect.width === width) return
width = entry.contentRect.width
resizeToContent()
})
resizeObserver.observe(element.value)
})
onBeforeUnmount(() => resizeObserver?.disconnect())
watch(resolvedValue, async () => {
await nextTick()
resizeToContent()
})
defineExpose({
element,
focus: (options) => element.value?.focus(options),
resize: resizeToContent
})
</script>
<template>
<textarea
ref="element"
v-bind="forwardedAttrs"
:value="resolvedValue"
data-slot="textarea"
:class="
twMerge(
[
'block h-(--klean-textarea-height) min-h-28 w-full resize-none overflow-y-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-base text-gray-950 shadow-sm outline-none transition-colors duration-150',
'placeholder:text-gray-500 hover:border-gray-400',
'focus-visible:border-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
'disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500',
'aria-invalid:border-red-600 aria-invalid:focus-visible:outline-red-600',
'dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:hover:border-gray-600 dark:focus-visible:border-white dark:focus-visible:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus-visible:outline-red-500',
'motion-reduce:transition-none'
],
attrs.class
)
"
@compositionstart="composing = true"
@compositionend="finishComposition"
@input="updateValue"
/>
</template>