Skip to content

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.

Preview Live · Vue
Usage
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>
jsx
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>
  )
}
svelte
<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
Textarea.vue
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.

Terminal
npx klean-ui add textarea

Source you own. Edit the installed files to make them yours.

Native 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:

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:

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>
jsx
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
svelte
<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>
  • 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.