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
| Prop | Default | Description |
|---|---|---|
Textarearenders a <textarea> element | ||
resizable | true | Lets the user drag the bottom edge to change the height. |
invalid | false | Draws the red border and sets aria-invalid. |
disabled | false | Turns the textarea off when it is not inside a disabled Field. |
| Prop | Default | Description |
|---|---|---|
Textarearenders a <textarea> element | ||
v-model | - | The current text, kept in sync as the user types. |
resizable | true | Lets the user drag the bottom edge to change the height. |
invalid | false | Draws the red border and sets aria-invalid. |
disabled | false | Turns 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>