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>

  • RichText — formatted HTML or Markdown with visual editing and source access.
  • 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.