Textarea
Let people enter multiple lines of text in a native textarea that grows with its content. Its height responds to restored values and changes in available width.
<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>import Textarea from '@/components/ui/textarea/Textarea.jsx'
export default function NoteField({ value, onChange, error = '' }) {
return (
<div className="grid gap-2">
<label htmlFor="note">Internal note</label>
<Textarea
id="note"
value={value}
onChange={(event) => onChange(event.target.value)}
name="note"
rows={3}
aria-invalid={Boolean(error)}
aria-describedby="note-help note-error"
/>
<p id="note-help">Plain text, up to 2,000 characters.</p>
<p id="note-error" className="empty:hidden text-sm text-red-700">
{error}
</p>
</div>
)
}<script>
import Textarea from '@/components/ui/textarea/Textarea.svelte'
let { value = $bindable(''), error = '' } = $props()
</script>
<div class="grid gap-2">
<label for="note">Internal note</label>
<Textarea
id="note"
bind:value
name="note"
rows="3"
aria-invalid={Boolean(error)}
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">
{error}
</p>
</div>View source Textarea.vue
<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>Installation
Run from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add textareaNative form recipe
Copy the example above for your framework.
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>import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react'
import { twMerge } from 'tailwind-merge'
const BASE_CLASSES = [
'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'
]
const Textarea = forwardRef(function Textarea(
{ value, defaultValue, className, onInput, ...props },
ref
) {
const element = useRef(null)
const setRefs = useCallback(
(node) => {
element.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref]
)
const resizeToContent = useCallback(() => {
if (!element.current) return
element.current.style.removeProperty('--klean-textarea-height')
element.current.style.setProperty(
'--klean-textarea-height',
`${element.current.scrollHeight}px`
)
}, [])
useLayoutEffect(() => {
resizeToContent()
}, [value, defaultValue, resizeToContent])
useLayoutEffect(() => {
if (!element.current || typeof ResizeObserver === 'undefined') return
let width = element.current.offsetWidth
const observer = new ResizeObserver(([entry]) => {
if (entry.contentRect.width === width) return
width = entry.contentRect.width
resizeToContent()
})
observer.observe(element.current)
return () => observer.disconnect()
}, [resizeToContent])
function handleInput(event) {
resizeToContent()
onInput?.(event)
}
return (
<textarea
{...props}
ref={setRefs}
value={value}
defaultValue={defaultValue}
data-slot="textarea"
className={twMerge(BASE_CLASSES, className)}
onInput={handleInput}
/>
)
})
export default Textarea<script>
import { onMount, tick } from "svelte";
import { twMerge } from "tailwind-merge";
const BASE_CLASSES = [
"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",
];
let { value = $bindable(), class: className, oninput, ...props } = $props();
let element;
function resizeToContent() {
if (!element) return;
element.style.removeProperty("--klean-textarea-height");
element.style.setProperty(
"--klean-textarea-height",
`${element.scrollHeight}px`,
);
}
function handleInput(event) {
resizeToContent();
oninput?.(event);
}
onMount(() => {
resizeToContent();
if (typeof ResizeObserver === "undefined") return;
let width = element.offsetWidth;
const observer = new ResizeObserver(([entry]) => {
if (entry.contentRect.width === width) return;
width = entry.contentRect.width;
resizeToContent();
});
observer.observe(element);
return () => observer.disconnect();
});
$effect(() => {
value;
tick().then(resizeToContent);
});
</script>
<textarea
bind:this={element}
{...props}
bind:value
data-slot="textarea"
class={twMerge(BASE_CLASSES, className)}
oninput={handleInput}></textarea>