Skip to content
  1. Inputs
  2. Textarea

Textarea

A multi-line text field for notes, messages and anything longer than a sentence.

import { Field, Label } from '@/components/fieldset'
import { Textarea } from '@/components/textarea'

export default function Example() {
  return (
    <Field>
      <Label>Invoice memo</Label>
      <Textarea name="memo" />
    </Field>
  )
}
<script setup lang="ts">
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'
</script>

<template>
  <Field>
    <Label>Invoice memo</Label>
    <Textarea name="memo" />
  </Field>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'

const wrapper = {
  layout: 'relative block w-full',
  background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
  focus: [
    'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
    'sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
  ],
  disabled: 'has-data-disabled:opacity-50 has-data-disabled:before:shadow-none has-data-disabled:before:bg-zinc-950/5',
}

const field = {
  layout: 'relative block h-full w-full appearance-none rounded-lg focus:outline-hidden',
  padding: 'py-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:px-[calc(--spacing(3)-1px)]',
  typography: 'text-base/6 sm:text-sm/6 text-zinc-950 placeholder:text-zinc-500',
  background: 'bg-transparent',
  border: 'border border-zinc-950/10 data-hover:border-zinc-950/20',
  invalid: 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
  disabled: 'disabled:border-zinc-950/20',
  dark: [
    'dark:text-white dark:bg-white/5 dark:border-white/10 dark:data-hover:border-white/20',
    'dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
    'dark:disabled:bg-white/2.5 dark:disabled:border-white/15 dark:data-hover:disabled:border-white/15',
  ],
}

type TextareaProps = { className?: string; resizable?: boolean } & Omit<Headless.TextareaProps, 'as' | 'className'>

export const Textarea = forwardRef(function Textarea(
  { className, resizable = true, ...props }: TextareaProps,
  ref: React.ForwardedRef<HTMLTextAreaElement>
) {
  return (
    <span data-slot="control" className={clsx(className, Object.values(wrapper))}>
      <Headless.Textarea ref={ref} {...props} className={clsx(Object.values(field), resizable ? 'resize-y' : 'resize-none')} />
    </span>
  )
})
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import { useDisabled, useFieldControl } from './fields'
import { vInteractions } from './interactions'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    resizable?: boolean
    invalid?: boolean
    disabled?: boolean
  }>(),
  { resizable: true }
)

const model = defineModel<string>()
const attrs = useAttrs()
const fieldControl = useFieldControl(attrs)

const wrapper = {
  layout: 'relative block w-full',
  background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
  focus: [
    'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
    'sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
  ],
  disabled: 'has-data-disabled:opacity-50 has-data-disabled:before:shadow-none has-data-disabled:before:bg-zinc-950/5',
}

const field = {
  layout: 'relative block h-full w-full appearance-none rounded-lg focus:outline-hidden',
  padding: 'py-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:px-[calc(--spacing(3)-1px)]',
  typography: 'text-base/6 sm:text-sm/6 text-zinc-950 placeholder:text-zinc-500',
  background: 'bg-transparent',
  border: 'border border-zinc-950/10 data-hover:border-zinc-950/20',
  invalid: 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
  disabled: 'disabled:border-zinc-950/20',
  dark: [
    'dark:text-white dark:bg-white/5 dark:border-white/10 dark:data-hover:border-white/20',
    'dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
    'dark:disabled:bg-white/2.5 dark:disabled:border-white/15 dark:data-hover:disabled:border-white/15',
  ],
}

const isDisabled = useDisabled(() => props.disabled)

const innerAttrs = computed(() => {
  const { class: _class, ...rest } = attrs
  const bindings: Record<string, unknown> = { ...rest }
  if (model.value !== undefined) bindings.value = model.value
  return bindings
})
</script>

<template>
  <span data-slot="control" :class="[attrs.class, Object.values(wrapper)]">
    <textarea
      v-bind="innerAttrs"
      v-interactions
      :id="fieldControl.id.value"
      :aria-describedby="fieldControl.describedBy.value"
      :disabled="isDisabled"
      :data-disabled="isDisabled ? '' : undefined"
      :aria-invalid="invalid ? 'true' : undefined"
      :data-invalid="invalid ? '' : undefined"
      :class="[Object.values(field), resizable ? 'resize-y' : 'resize-none']"
      @input="model = ($event.target as HTMLTextAreaElement).value"
    />
  </span>
</template>

Component API

PropDefaultDescription
Textarearenders a <textarea> element
resizabletrueLets the user drag the bottom edge to change the height.
invalidfalseDraws the red border and sets aria-invalid.
disabledfalseTurns the textarea off when it is not inside a disabled Field.
PropDefaultDescription
Textarearenders a <textarea> element
v-model-The current text, kept in sync as the user types.
resizabletrueLets the user drag the bottom edge to change the height.
invalidfalseDraws the red border and sets aria-invalid.
disabledfalseTurns the textarea off when it is not inside a disabled Field.

Usage

Reach for a textarea when the answer is free-form and can run over one line: a memo, a reply, a reason. Names, references and anything with a fixed format belong in an Input.

Write a longer note

Wrap the Textarea and its Label in a Field so they are linked without writing ids:

import { Field, Label } from '@/components/fieldset'
import { Textarea } from '@/components/textarea'

export default function Example() {
  return (
    <Field>
      <Label>Invoice memo</Label>
      <Textarea name="memo" />
    </Field>
  )
}
<script setup lang="ts">
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'
</script>

<template>
  <Field>
    <Label>Invoice memo</Label>
    <Textarea name="memo" />
  </Field>
</template>

Say where the text ends up

People write differently when they know who reads it. A Description after the control answers that, and is announced with the field:

Printed under the line items of every invoice sent to this customer.

import { Description, Field, Label } from '@/components/fieldset'
import { Textarea } from '@/components/textarea'

export default function Example() {
  return (
    <Field>
      <Label>Invoice memo</Label>
      <Textarea name="memo" />
      <Description>Printed under the line items of every invoice sent to this customer.</Description>
    </Field>
  )
}
<script setup lang="ts">
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'
</script>

<template>
  <Field>
    <Label>Invoice memo</Label>
    <Textarea name="memo" />
    <Description>Printed under the line items of every invoice sent to this customer.</Description>
  </Field>
</template>

Layout

Room for a real message

The height follows the native rows attribute. Size it to the length you expect, so people do not have to scroll inside the box to reread what they wrote:

<Field>
  <Label>Message to the customer</Label>
  <Textarea name="message" rows={6} />
</Field>
<Field>
  <Label>Message to the customer</Label>
  <Textarea name="message" rows="6" />
</Field>

Keep a tight layout intact

By default the bottom edge can be dragged to grow the box vertically. Pass resizable as false in a side panel or a table row, where a taller field would push the actions out of view:

<Field>
  <Label>Payout note</Label>
  <Textarea name="payout_note" rows={2} resizable={false} />
</Field>
<Field>
  <Label>Payout note</Label>
  <Textarea name="payout_note" rows="2" :resizable="false" />
</Field>

States

Locked after a status change

disabled on the Field turns off the textarea and dims the label and description. The text stays readable, which makes it a fair way to show history that can no longer be edited:

Notes are locked once an invoice is paid.

<Field disabled>
  <Label>Internal notes</Label>
  <Textarea name="internal_notes" defaultValue="Customer asked for net 30 terms on the next renewal." />
  <Description>Notes are locked once an invoice is paid.</Description>
</Field>
<Field disabled>
  <Label>Internal notes</Label>
  <Textarea name="internal_notes" value="Customer asked for net 30 terms on the next renewal." />
  <Description>Notes are locked once an invoice is paid.</Description>
</Field>

Outside a Field, put disabled on the Textarea itself.

Validation

Ask for a missing reason

invalid gives the textarea a red border and sets aria-invalid. The ErrorMessage below it is linked to the control, so it is read when focus comes back:

Add a reason. The customer sees it on the voided invoice.

import { ErrorMessage, Field, Label } from '@/components/fieldset'
import { Textarea } from '@/components/textarea'

export default function Example({ errors }) {
  return (
    <Field>
      <Label>Reason for voiding</Label>
      <Textarea name="void_reason" invalid={errors.has('void_reason')} />
      {errors.has('void_reason') && <ErrorMessage>{errors.get('void_reason')}</ErrorMessage>}
    </Field>
  )
}
<script setup lang="ts">
import ErrorMessage from '@/components/error-message.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'

defineProps<{ errors: Map<string, string> }>()
</script>

<template>
  <Field>
    <Label>Reason for voiding</Label>
    <Textarea name="void_reason" :invalid="errors.has('void_reason')" />
    <ErrorMessage v-if="errors.has('void_reason')">{{ errors.get('void_reason') }}</ErrorMessage>
  </Field>
</template>

Interaction

Count what is left

With value and onChange the text lives in state, and a counter in the Description updates on every keystroke. maxLength still stops typing at the limit:

With v-model the text lives in a ref, and a counter in the Description updates on every keystroke. maxlength still stops typing at the limit:

165 characters left

'use client'

import { useState } from 'react'
import { Description, Field, Label } from '@/components/fieldset'
import { Textarea } from '@/components/textarea'

export default function Example() {
  let [footer, setFooter] = useState('Thanks for building with northwind.')

  return (
    <Field>
      <Label>Receipt footer</Label>
      <Textarea name="receipt_footer" maxLength={200} value={footer} onChange={(event) => setFooter(event.target.value)} />
      <Description>{200 - footer.length} characters left</Description>
    </Field>
  )
}
<script setup lang="ts">
import { ref } from 'vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import Textarea from '@/components/textarea.vue'

const footer = ref('Thanks for building with northwind.')
</script>

<template>
  <Field>
    <Label>Receipt footer</Label>
    <Textarea name="receipt_footer" maxlength="200" v-model="footer" />
    <Description>{{ 200 - footer.length }} characters left</Description>
  </Field>
</template>