Skip to content

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.

Textarea.vue

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.

Terminal
npx klean-ui add textarea

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

Native form recipe

NoteField.vue
<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:

vue
<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-invalid with 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:

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>

  • Input — single-line native input.
  • Button — submit or act on form data.
  • Toast — announce the result after a draft is saved.
  • Dialog — compose a focused modal editing task when inline space is inappropriate.

All open source projects are released under the MIT License.