A multi-line Text field, with the same label, hint, error, changed state and length count.

Basic

Pass a label, and a hint if the label cannot say it all. The box is three lines tall and never shorter than 6rem. Enter adds a line, and the reader can drag the corner to make it taller. The placeholder shows an example and never replaces the label.

Example

Changed
One or two sentences, shown in search results and invites.
Follows the page theme and corners
'use client';

import { TextArea } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function TextAreaBasic(): ReactElement {
  return (
    <TextArea
      label="Short description"
      description="One or two sentences, shown in search results and invites."
      placeholder="A shared space for the Northwind design team"
      style={{ width: '100%', maxWidth: '24rem' }}
    />
  );
}

States

The states match Text field, on the taller box.

  • Changed: a light signal orange edge and a clay chip beside the label.
  • Invalid: the error edge and an error line. Invalid outranks changed, so the chip steps aside.
  • Disabled: recessed.
  • Read-only: looks enabled and still submits.

Example

Changed
Write at least one full sentence.
Changed
Changed
Follows the page theme and corners
'use client';

import { TextArea } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function TextAreaStates(): ReactElement {
  return (
    <div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
      <TextArea
        label="Welcome message"
        defaultValue={'Welcome to Northwind.\nStart with the brief in the pinned note.'}
        isChanged
      />
      <TextArea
        label="Invite note"
        defaultValue="Hi!"
        isInvalid
        errorMessage="Write at least one full sentence."
      />
      <TextArea label="Archived note" defaultValue="Kept for the 2025 audit." isDisabled />
      <TextArea label="Plan terms" defaultValue="Billed yearly, cancel any time." isReadOnly />
    </div>
  );
}

Length and height

Pass budget to count the value's length against a rough target. The count appears from 70% of the budget and never blocks typing. Pass rows for a taller box: the height is the rows times the line height, plus padding and edge, and never less than 6rem.

Example

Changed
Keep typing past 120. Nothing stops you.93 of about 120
Follows the page theme and corners
'use client';

import { TextArea } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function TextAreaCounter(): ReactElement {
  return (
    <TextArea
      label="About the workspace"
      description="Keep typing past 120. Nothing stops you."
      defaultValue={
        'A shared space for the Northwind design team.\nBriefs, reviews and the weekly notes live here.'
      }
      budget={120}
      rows={5}
      style={{ width: '100%', maxWidth: '24rem' }}
    />
  );
}
  • Units: the count is in UTF-16 code units, as JavaScript and maxLength measure it. A line break counts as one, as in the value onChange receives. An emoji, or an accented letter built from two code points, can count as two.
  • Forms: a browser sends each line break as two characters, so a server measuring the request sees one more per line.
  • Limits: the budget is a soft target the count can pass. maxLength is a hard stop the counter does not read, so set budget at or below maxLength.

To word the count another way, such as in another language, pass counter with a label function. It receives the count, the budget and whether the count is past it, and returns the text. counter.showFrom sets the share of the budget where the count first appears.

<TextArea
  label="Kurzbeschreibung"
  budget={60}
  counter={{ label: ({ count, budget }) => `${count} von etwa ${budget}` }}
/>

The count is announced each time it changes. If that is too often, build the field from its parts and pass FieldCounter a count that settles after a pause, as Field parts describes.

Usage

Every value works controlled and uncontrolled, and a field with a name submits in a plain form. onChange receives the string, with each line break as \n.

import { useState } from 'react';
import { TextArea } from '@snapshot-ui/react';

export function ShortDescription({ saved }: { saved: string }) {
  const [value, setValue] = useState(saved);
  return (
    <TextArea
      label="Short description"
      description="One or two sentences, for search results."
      name="description"
      value={value}
      onChange={setValue}
      isChanged={value !== saved}
      budget={120}
      rows={4}
    />
  );
}

The types require an accessible name: a label, an aria-label when no label is shown, or an aria-labelledby that points at a label elsewhere.

For a custom layout, use Snapshot UI's Label, Description and FieldError inside React Aria's TextField, with React Aria's TextArea and the classes input textarea. There is no bare textarea part, since those classes are all it would add. Field parts has the layout.

Props

Every React Aria TextField prop is accepted except type and pattern, which belong to a single line. These are the ones Snapshot UI adds or shapes.

budget
number

About how long the value should be. Shows a length count from 70% of the budget. It never blocks typing, so the count can pass it. The length is in UTF-16 code units, as maxLength measures it. Set budget at or below maxLength, which the count does not read.

changedLabel
string

The text of the Changed chip.

Default: 'Changed'

counter
Pick<FieldCounterProps, "label" | "showFrom">

Options for the budget count. label returns its text from the count, the budget and whether the count is past it. showFrom is the share of the budget, from 0 to 1, where the count appears (0.7 by default).

defaultValue
string

The default value (uncontrolled).

description
ReactNode

A hint under the control. It becomes the control's accessible description.

errorMessage
((validation: ValidationResult) => ReactNode) | ReactNode

The error line, shown only while the field is invalid. A function receives the validation result. Without it the field shows its own validation messages.

isChanged
boolean

Whether the value differs from the saved one. Shows the clay Changed chip and the light signal orange border, and a screen reader reads the chip with the field. An invalid field shows its error instead.

isDisabled
boolean

Whether the input is disabled.

isInvalid
boolean

Whether the value is invalid.

isReadOnly
boolean

Whether the input can be selected but not changed by the user.

isRequired
boolean

Whether user input is required on the input before form submission.

label
ReactNode

The field's name, shown above the control. Required unless the field has an aria-label or an aria-labelledby.

name
string

The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).

onChange
((value: string) => void)

Handler that is called when the value changes.

placeholder
string

Example text shown while the control is empty. It never replaces the label.

rows
number

How many lines tall the control is. It is never shorter than 6rem (three lines and their padding), so fewer rows look the same. The reader can drag the corner to resize it.

Default: 3

textAreaRef
Ref<HTMLTextAreaElement>

A ref to the textarea element.

validate
((value: string) => ValidationError | true | null | undefined)

A function that returns an error message if a given value is invalid. Validation errors are displayed to the user when the form is submitted if validationBehavior="native". For realtime validation, use the isInvalid prop instead.

value
string

The current value (controlled).

Accessibility

A native textarea, labelled by the label and described by the hint and error line, with React Aria handling the wiring. While the field is changed and valid, a screen reader reads the Changed chip after the hint. An invalid field reads its hint, then its error line.

TabMoves focus into the field. The edge firms to 1.5px ink and the 2px ring sits outside it.
ShiftTabMoves focus back out.
EnterAdds a new line. It never submits the form.
  • Resizing: the corner handle works with a pointer, in browsers that draw one. The keyboard cannot resize, so set rows to the height the content needs.
  • Placeholder: taupe and quiet by design, so it never carries anything the label does not.
  • Count: a polite status region.
  • Missing name: the field does not compile. TypeScript either names only aria-labelledby or says the props are not assignable. Both mean the field has no accessible name.

Design rules

What keeps fields in the system.

Name the field in the label, in sentence case. Use the hint for what the label cannot say.

Use a text area for text that runs to a sentence or more. A name, an email or a title is a text field.

Set rows to the length you expect, so the box starts at the right height.

Never use the placeholder as the label.

Never block typing at the budget. The count advises.

Never leave the chip beside an error. Invalid outranks changed.

esc

↑ ↓ to move↵ to open