Components

Textarea

How to build a textarea that people can label, write in at length and fix, that grows or resizes without cutting anything off, and that agents can check.

Textareas are where people write more than a line, like a comment, a message, a description or a delivery note. They look like an input that's a few lines taller, which is why they're so often built as one and inherit all of its problems, along with a few of their own. Think of a box too small to see what you've written, a limit that silently cuts off the end of what you pasted, or an Enter key that sends a message you were still writing.

This page walks you through building one the right way. We'll look at:

  • User Interface (UI) - how a textarea looks
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to write in them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Textarea with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Textarea is the box you write in, like the Input is for a single line. Its label, hint and error message are elements of their own, which you link to it by ID, and a Field component, which is on our Roadmap, will put them together for you. Until then, the examples do it with a plain <label>, and the rules hold either way.

<label htmlFor="message">Message</label>
<Textarea id="message" value={value} onChange={(event) => setValue(event.target.value)} />

At a glance

AspectTextarea
Element<textarea>, inside a <div> that holds its count
Roletextbox, with aria-multiline implied, so screen readers can tell it from an input
NameIts <label>, linked with for, and never its placeholder
KeyboardThe browser's own text editing, with Enter for a new line, which never submits its form
StatesHover, focus, disabled, read-only and invalid
HeightAt least rows lines, resized by people on the block axis, or growing with its text up to maxRows
CountOptional, against a limit, which people can type and paste past
Sizesxs, sm, md, lg and xl, with the same padding and text as the Input
PatternNone in the APG, since a native <textarea> covers it
WCAG 2.21.3.1, 1.4.3, 1.4.4, 1.4.10, 1.4.11, 2.4.7, 2.5.8, 3.3.1, 3.3.2, 4.1.2 and 4.1.3, among others

User Interface (UI)

Let's start with how a textarea looks: its parts, its sizes, how tall it is, how it counts what people write and how it shows each of its states.

Anatomy

  1. Container — The root <div>, which carries the size and holds the count below the field.
  2. Control — The <textarea> itself, which draws the field, holds the text and takes focus. Its label names it.
  3. Resize handle — The browser's own, in the corner, which people drag to make the field taller or shorter.
  4. Count — Optional. How many characters are left, or how many too many, which describes the control.
  5. Focus ring — An outline around the field, drawn outside it so nothing moves.

Every textarea has a container and a control. Unlike the Input, the control draws the field itself, since its resize handle and its scrollbar belong to it. The label, any hint and any error message sit outside it, in the page, and point to the control by its id (see Label every textarea). The parts always render in the same order, which you can see in the DOM contract.

Sizes

The size prop scales the text, padding and corner radius together, and md is the default. They're the same as the Input's, so a textarea and an input of the same size line up in a form, and a textarea of one row is exactly as tall as an input.

<label htmlFor="subject-xs">Subject</label>
<Input id="subject-xs" size="xs" />
<label htmlFor="message-xs">Message</label>
<Textarea id="message-xs" size="xs" rows={1} />
{/* …sm, md, lg, xl */}
SizeOne rowTextLine heightPaddingRadius
xs24px12px18px8px6px
sm32px14px21px10px6px
md36px14px21px12px8px
lg40px16px24px14px8px
xl48px16px24px16px12px

Sizes are set in rem, and the table shows the values we recommend at the default text size of 16px. The line height is 1.5 times the text, a little more than the Input's, since people read what they've written back over several lines. The padding above and below the text is whatever's left of one row's height, so every size meets the 24 × 24px minimum from WCAG 2.5.8. On touch screens, the text grows to at least 16px, since Safari on iOS zooms in on a textarea with smaller text as soon as it gets focus, and the field is at least 44px tall, whatever its rows.

Height

A textarea's height says how much people are expected to write, just like an input's width. rows sets how many lines it shows, and it's never shorter than that, so it's the first thing to pick. From there, the height can go one of two ways. Either people resize it, by dragging the handle in its corner, or it grows with its text, with autoGrow, up to maxRows.

<label htmlFor="address">Street address</label>
<Textarea id="address" rows={3} autoComplete="street-address" />

<label htmlFor="comment">Comment</label>
<Textarea id="comment" rows={2} autoGrow maxRows={6} />
  • Pick rows for the text you expect, so people can see all of it as they write. Three rows suit a short note, while a message or a description might need five or more. A box that's too small makes people scroll inside it to check what they wrote, which is hard for everyone and harder still at 200% zoom.
  • A resizable textarea only resizes on the block axis, taller or shorter, since a textarea that gets wider than its column breaks the layout around it (WCAG 1.4.10). It never gets shorter than its rows.
  • An auto-growing textarea has no handle, since there's nothing left for people to resize. It grows with field-sizing: content where the browser supports it, and elsewhere the Textarea sets its height from a script as people type.
  • Give an auto-growing textarea a maxRows, so that a long text doesn't push the rest of the form, and its submit button, off the screen. Past it, the text scrolls inside the field.
  • We recommend letting it fill the width of its column, since lines that run the whole width of a wide screen are hard to read back, and a narrow textarea wraps them short. Set its width from a class if the column is wider than 80 characters, which is as wide as a block of text should get (WCAG 1.4.8).

Character count

When there's a limit on how much people can write, like a bio for a profile or a message to a support team, limit shows a count under the field. It says how many characters are left, and once people go over, how many too many. We recommend saying what the limit is in the hint too, so people know it before they start.

You can enter up to 160 characters

18 characters left
<label htmlFor="bio">Bio</label>
<p id="bio-hint">You can enter up to 160 characters</p>
<Textarea id="bio" limit={160} aria-describedby="bio-hint" />
  • The count never stops people typing or pasting, unlike maxlength. Over the limit, it turns red, says by how much, and marks the textarea invalid, so people can decide what to cut (see Don't cut people off).
  • The count describes the textarea, after any hint, so screen readers read it when the textarea gets focus. As people near the limit, it's also announced once they pause typing, rather than on every keystroke.
  • The count is named after the textarea's id, as in bio-count, so give every textarea with a limit an id, which its label needs anyway.
  • The count's text is in English. To write it in the page's language, pass countLabel, which gets the number of characters left, and a negative number over the limit.

States

Each state has a look of its own, styled from the attribute on the <textarea> that exposes it. That way, what people see and what assistive technologies report can never drift apart, and a textarea can't look invalid without actually being invalid.

At rest (try hovering, clicking or tabbing to it)
Disabled
Read-only

Enter a reason for returning the item

Invalid
<label htmlFor="message">Message</label>
<Textarea id="message" rows={2} defaultValue="Could we move our call to Friday?" />

<label htmlFor="notes">Notes</label>
<Textarea id="notes" rows={2} defaultValue="Call back after 3pm" disabled />

<label htmlFor="terms">Terms</label>
<Textarea id="terms" rows={2} defaultValue="Payment is due within 30 days." readOnly />

<label htmlFor="reason">Reason for return</label>
<Textarea id="reason" rows={2} aria-invalid="true" aria-describedby="reason-error" />
<p id="reason-error">Enter a reason for returning the item</p>
StateSelectorRecommended look
Hover:hover, inside @media (hover: hover)The border at 80% of the text colour, up from 55%
Focus:focus-visibleA 2px outline, 2px from the edge, in --color--focus
Disabled:disabled50% opacity, for the count too, a not-allowed cursor and no resize handle
Read-only[readonly]A dashed border, with the text at full contrast, and the default cursor
Invalid[aria-invalid="true"]A 2px border in --color--error-text

The <textarea> draws the field, so it reads each state from its own attributes, and the root only dims the count along with it while it's disabled. We recommend leaving :invalid and :user-invalid out of it. The browser decides them from its own checks, like required, so :invalid matches an empty required textarea before anyone has written in it, and neither of them knows what your error message says. The invalid look comes from aria-invalid instead, which you set when the error message shows, and which the Textarea sets itself over its limit.

None of the states change the textarea's size or position. The invalid border's second pixel is drawn inside the field, and the focus ring outside it. You'll find how each state behaves under Every state.

Tokens

The textarea reads three theme tokens, which every component shares:

TokenUsed for
--color--neutral-textThe text, and mixed into the border, the placeholder and the count
--color--error-textThe border of an invalid textarea, and the count over its limit
--color--focusThe focus ring, which is the same for every component

There's no color prop, so the textarea doesn't read any of the other colours. Everything else is mixed from the text colour, as on the Input: 55% for the border, which needs 3:1 against the page, 80% for the border on hover, and 70% for the placeholder and the count, which need 4.5:1. With our reference tokens, from the Tailwind CSS palette, the border comes to 3.5:1 and the placeholder to 5.6:1 on white, and 5.0:1 and 7.4:1 on zinc-900. If you change the tokens, make sure to check the same pairs. You'll find the tokens themselves in the Button's tokens.css, which you can copy into your theme.

The Textarea's own variables are set on its root:

VariableWhat it sets
--textarea--heightThe height of one row, the same as the Input's, which sets the padding
--textarea--paddingThe padding before and after the text
--textarea--padding-blockThe padding above and below the text, from the height of one row
--textarea--font-sizeThe text, and the count
--textarea--radiusThe corners
--textarea--border-colorThe border, which hover, invalid and more contrast change
--textarea--min-heightThe height of rows lines, which resizing and growing never go below
--textarea--max-heightThe height of maxRows lines, which growing stops at
--textarea--count--colorThe count, until it's over the limit
--textarea--rows, --textarea--max-rowsrows and maxRows, which the Textarea sets itself, so set the props rather than these

If you need to tune a textarea, you can set any of the others from a class, like --textarea--radius or --textarea--min-height. The Textarea's styles sit in the components cascade layer, so your class wins without any specificity tricks:

Feedback.tsx
import styles from "./Feedback.module.css";

export function Feedback() {
  return (
    <>
      <label htmlFor="feedback">Feedback</label>
      <Textarea id="feedback" className={styles.soft} />
    </>
  );
}
Feedback.module.css
.soft {
  --textarea--radius: 1rem;
}

User Experience (UX)

Now let's look at how a textarea behaves for every person, input and setting. A good textarea is accessible, predictable, designed in every state and adaptive.

Accessible

Use the native element

A native <textarea> gives you focus, text editing, new lines, selection, copy and paste, undo, spellcheck, autofill, scrolling, resizing and form submission, all for free. A contenteditable element with role="textbox" only gives you the editing and a role. It isn't submitted with its form, autofill skips it, pasting brings in the formatting of wherever the text came from, and screen readers and voice control handle it less reliably than a native field, so every one of those is one more thing to rebuild by hand.

{/* Do: you get editing, new lines, autofill and form submission for free */}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={4} />

{/* Don't: there's no form submission or autofill, and pasting brings in formatting */}
<div className="textarea" role="textbox" aria-multiline="true" contentEditable onInput={update} />

If people need formatting, like bold text and lists, that's a rich text editor, which is a different component with a lot more to it. For plain text, keep to the <textarea>.

Label every textarea

The label is also the textarea's accessible name. Screen readers read it when the textarea gets focus, voice control users say it out loud ("click Message"), and tests and agents use it to find the textarea.

Include what you were doing, and any error message you saw

<label htmlFor="details">What went wrong?</label>
<p id="details-hint">Include what you were doing, and any error message you saw</p>
<Textarea id="details" rows={4} aria-describedby="details-hint" />
  • Every textarea has a visible <label>, linked to it with for and the textarea's id (WCAG 1.3.1, 3.3.2). Clicking the label focuses the textarea too.
  • A hint, like what to include or how long it can be, goes in the textarea's description, which screen readers read after its name. You can do that with aria-describedby, pointing at the hint's id. The Textarea adds its count after it.
  • If you name a textarea with aria-label or aria-labelledby, make sure the name includes the visible label, ideally at the start (WCAG 2.5.3).
  • The Textarea doesn't make up IDs of its own (see Deterministic), so give it an id for its label to point at, and one to each hint and error message that points back at it.

A placeholder isn't a label. It disappears as soon as people start writing, so they can't check what they were asked for, it's lighter than the text around it, so it's harder to read, and it isn't a reliable name for assistive technologies. In a textarea, a long placeholder also looks like text that's already been written. If you use one at all, keep it to a short example, and put anything people need to know in the hint.

Contrast and size

  • The text needs a contrast of at least 4.5:1 with its background, and so do the placeholder and the count, since people need to read them too (WCAG 1.4.3). Disabled textareas are exempt, but they should still be readable.
  • The border needs 3:1 against the page (WCAG 1.4.11). An empty textarea is nothing but its border, so it's how people see that there's something to fill in. The focus ring needs 3:1 as well.
  • Every textarea is at least 24px tall (WCAG 2.5.8), and on touch screens at least 44px (WCAG 2.5.5), however few rows it has.

Show focus

When a textarea has focus, it shows an outline in the same focus colour as every other component (WCAG 2.4.7). Like the Button's and the Input's, it's at least 2px thick and drawn outside the border, and we recommend 2px for both. Browsers match :focus-visible on a textarea however it got focus, since people are about to write either way, so the ring shows after a click too.

You'll also want to keep focused textareas clear of sticky headers and footers, which you can do with scroll-padding (WCAG 2.4.11, 2.4.12). On phones, the on-screen keyboard covers the bottom of the page too, and an auto-growing textarea gets taller as people write, so check that the line they're writing on stays in view above it.

Predictable

Leave typing to the browser

The Textarea leaves text editing to the browser, so moving the caret, selecting, copying, cutting, pasting, undoing and spellchecking all work just as they do everywhere else. We recommend never taking any of them over, and never blocking paste. People write longer text somewhere else and paste it in, like a cover letter or a bug report, and blocking paste makes them type it all again by hand.

Keyboard

KeyTextarea
Tab, Shift+TabMove focus to and from the textarea
Arrows, Home and EndMove the caret, line by line and within a line
EnterStarts a new line, and never submits its form

Enter starts a new line in a textarea, which HTML leaves out of implicit submission, so a form with a textarea needs a submit button that people can reach with Tab. We recommend never making Enter send the form, even in a chat. People press it for a new line out of habit, and a message sent half-written can't be taken back. If you'd like a shortcut to send, use one that can't be pressed by accident, like Ctrl+Enter or Cmd+Enter, and say what it is next to the submit button.

Tab moves focus out of a textarea rather than typing a tab, as it should, so keyboard users never get stuck in it (WCAG 2.1.2). If your textarea is for code, where people expect Tab to indent, make sure Escape then Tab still moves focus on, and say so in the hint.

Don't cut people off

maxlength stops people typing past a limit, but it also silently cuts off what they paste, and they may never notice that the end is missing. The Textarea's limit counts instead. People can type and paste past it, the count says by how much, and the textarea is marked invalid until they've cut it back, so they decide what to keep. Check the limit on the server too, since the count only helps people stay within it.

The count is a status message, so screen reader users hear it without leaving the textarea (WCAG 4.1.3). It would talk over them if it were announced on every keystroke, though, so the Textarea waits until people pause for a second, and only announces it once they've used 80% of the limit, or gone over. It goes through the same polite live region as the Button's loading announcement, one for the whole page, which you can read about under the Button's Loading.

Help people fill it in

  • If a textarea asks for the person's own address, give it autocomplete="street-address", which the HTML standard keeps for a multi-line address (WCAG 1.3.5). Browsers then fill it in for them, which helps everyone, and most of all people who find typing hard.
  • Leave spellcheck on for prose, as browsers already do for a textarea. Turn it off with spellcheck="false" only for text that isn't words, like code or a list of IDs.
  • Keep what people have written if something goes wrong, like a failed submit or a page they leave by accident. Losing a long message is far worse than losing a single field.

Every state

Disabled

A disabled textarea can't be focused, edited or submitted. disabled sets the native attribute, so the textarea leaves the tab order, screen readers announce it as dimmed or unavailable when they read through the page, and its form leaves its text out when it's sent. It can't be resized either, since nobody can write in it, so there's nothing to make room for. If people need to read or copy the text, and the form needs to send it, the textarea should be read-only instead.

We recommend disabling as little as you can, since a disabled textarea can't explain why it's disabled, and keyboard users can't even reach it to find out. Never disable one while it has focus, either, like while its form is being sent. Disabling a focused textarea takes its focus away, and keyboard and screen reader users lose their place. Make it read-only while the form is sent instead.

Read-only

A read-only textarea shows text that people can't change, like the terms of an agreement or a message they've already sent. It stays in the tab order, so people can focus it, scroll it, select the text and copy it, screen readers announce it as read-only, and its form still sends it. readonly is a native attribute, so it lands on the <textarea> like any other. We recommend a dashed border, which tells it apart from a textarea you can write in, while the text keeps its full contrast. If the text never changes, a paragraph might be a better fit.

Invalid

An invalid textarea has text that needs fixing, and an error message that says how. The Textarea shows the state, and the page decides when to show it, since only the page knows what makes the text right. The one exception is its limit: over it, the Textarea marks itself invalid, and its count says by how much.

You can enter up to 200 characters

200 characters left

import { useRef, useState, type FormEvent } from "react";
import { flushSync } from "react-dom";

const LIMIT = 200;

export function Contact() {
  const [message, setMessage] = useState("");
  const [error, setError] = useState("");
  const [status, setStatus] = useState("");
  const textarea = useRef<HTMLTextAreaElement>(null);

  function update(value: string) {
    setMessage(value);
    // Once an error shows, check again as people type, so it clears as soon as it's fixed.
    if (error) setError(validateMessage(value, LIMIT));
  }

  async function send(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const text = validateMessage(message, LIMIT);
    if (text) {
      // Focus once the error is in the page, so screen readers read it with the textarea.
      flushSync(() => {
        setError(text);
        setStatus("Couldn't send your message. Check what you've written.");
      });
      textarea.current?.focus();
      return;
    }
    await api.send(message);
    setStatus("Message sent");
  }

  return (
    <form noValidate onSubmit={send}>
      <label htmlFor="message">Message</label>
      <p id="message-hint">You can enter up to {LIMIT} characters</p>
      <Textarea
        ref={textarea}
        id="message"
        limit={LIMIT}
        required
        value={message}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? "message-error message-hint" : "message-hint"}
        onChange={(event) => update(event.target.value)}
      />
      {error && <p id="message-error">{error}</p>}
      <Button type="submit" color="primary">Send</Button>
      <p role="status">{status}</p>
    </form>
  );
}

validateMessage stands in for your own check, which returns an error message, like "Enter a message", or "Message must be 200 characters or fewer" over the limit, and an empty string when the message is fine. Try pressing Enter in the example: it starts a new line, and only Send sends the form.

  • Set aria-invalid="true" on the textarea while its error message shows, and point aria-describedby at the message, before the hint. Screen readers then announce the textarea as invalid, and read the message right after its name (WCAG 3.3.1).
  • Say what's wrong and how to fix it, as in "Message must be 200 characters or fewer", rather than "Invalid input" (WCAG 3.3.3). A red border alone can't say any of that, and it means nothing to people who can't tell red apart (WCAG 1.4.1).
  • Check the text when people submit, or when they leave the textarea, and not while they're still writing it. Once an error shows, check again as they type, and clear it as soon as the text is right. The count is the exception, since it's how people keep within the limit as they write.
  • When a submit fails, move focus to the first invalid field, once the error is in the page, so keyboard and screen reader users land right on it and hear its error. As on the Input, a status message says what happened, since moving focus to a field that already has it doesn't announce anything.
  • Put novalidate on the form, so that the browser's own error bubbles don't compete with yours, and keep required on the textarea, which tells screen readers that it's required.

Adaptive

Pointer, touch and pen

  • Hover styles only apply to pointers that can actually hover (@media (hover: hover)), otherwise a tap would leave the border looking hovered.
  • On touch screens, the textarea is at least 44px tall, whatever its rows, and its text is at least 16px, whatever its size. Safari on iOS zooms in on a textarea with smaller text as soon as it gets focus, and people then have to zoom back out to see the rest of the form.
  • The resize handle is small, and hard to drag with a finger, so on touch screens we recommend giving the textarea enough rows, or autoGrow, rather than relying on it.

More contrast

When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the border is drawn in the text colour at full strength, rather than at 55%. The text, the placeholder and the count already pass 4.5:1, so they stay as they are.

Forced colours

Windows contrast themes replace every colour with the user's own, and drop backgrounds and shadows. The Textarea's border stays, since the system draws it in a colour of its own, focus is drawn with outline, and a disabled textarea is marked with GrayText. A read-only textarea's border stays dashed. The invalid border's second pixel is a shadow, and the count's red is a colour, which forced colours drop, so in these themes it's the words, in the error message and the count, that tell people something's wrong.

Colour scheme

The tokens use light-dark(), which follows color-scheme, so the Textarea adapts to your light and dark themes on its own. Setting color-scheme on your themes also lets the browser match the parts of the textarea it draws itself, like its scrollbar and its resize handle.

Text size and spacing

Heights are minimums, in lines and rem, so the field grows with its text instead of clipping it, at 200% text size (WCAG 1.4.4) and with wider line, letter and word spacing (WCAG 1.4.12). Each row is one line of text at the textarea's own line height, with the lh unit, so a textarea of three rows still shows three lines when people widen the line spacing. The text wraps within the width of the field, so it never scrolls sideways.

Languages and direction

The Textarea uses logical properties, and resizes on the block axis rather than the vertical one, so it works the same in right-to-left languages and in vertical writing. What people write needs a little help, though:

<div dir="rtl" lang="ar">
  <label htmlFor="message">الرسالة</label>
  <Textarea id="message" dir="auto" rows={3} />
</div>
  • People can write in either direction, so give a textarea dir="auto", which lands on the <textarea>, and lets it follow whatever they write, while the page around it keeps its own direction.
  • Translate the count along with labels, hints and error messages, with countLabel, and mark any text that's in another language with lang, so that screen readers pronounce it correctly (WCAG 3.1.2).

Developer Experience (DX)

Next, let's look at how developers use the Textarea. It has the Input's API, with the same names as every other component, it's typed end to end, and it works with your forms however you build them.

import { useState } from "react";
import { Textarea } from "@/components/Textarea";

export function Profile() {
  const [bio, setBio] = useState("");

  return (
    <>
      <label htmlFor="bio">Bio</label>
      <p id="bio-hint">You can enter up to 160 characters</p>
      <Textarea
        id="bio"
        limit={160}
        aria-describedby="bio-hint"
        value={bio}
        onChange={(event) => setBio(event.target.value)}
      />
    </>
  );
}

Consistent

Once you've learned the Input's API, you already know most of the Textarea's. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
sizePropxs, sm, md, lg or xl, with the same text and padding everywhere
disabledPropThe component can't be used
valuePropThe value, which you bind the way your framework binds a form control's

The rest are the Textarea's own: rows, the native attribute, autoGrow and maxRows for its height, and limit and countLabel for its count. We recommend size="md" and rows="3" as the defaults. The Textarea also leaves a few props out, on purpose:

  • There's no variant or color, for the same reasons as the Input: the fields in a form are all equally important, a field without a border would be invisible when it's empty, and its one coloured state, invalid, comes from aria-invalid.
  • There's no label, hint or error. Each of them is an element of its own, linked by ID, which the Field on our Roadmap will render.
  • There's no maxlength either. limit takes its place, since it counts without cutting anything off. The native attribute still lands on the <textarea> if you pass it, but we recommend against it (see Don't cut people off).
  • There's no resize. A textarea people resize on the block axis, or one that grows on its own, covers what people need, and turning resizing off without growing leaves them stuck with whatever height you picked. The one exception is a disabled textarea, which has no resize handle, since nobody can write in it then.
  • There are no leading, trailing or actions slots. A textarea holds plain text over several lines, so an icon or a button inside it would sit in the way of what people write. Put buttons, like Send, after it in the form.

Type-safe

Props and events are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like rows="three" or size="medium".

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl""md"How big its text and padding are
rowsnumber3How many lines it shows, and is never shorter than
autoGrowbooleanfalseGrows with its text instead of scrolling, and can't be resized
maxRowsnumberHow many lines it grows to with autoGrow, before it scrolls
limitnumberThe most characters it takes, which shows a count and never cuts people off
countLabel(remaining: number) => stringThe count's text, for the characters left, which are negative over the limit
disabledbooleanfalseBlocks focus, editing and submission
valuestringThe value, when you bind it. Without it, the textarea keeps its own
EventPayloadDescription
inputEventFires as people type
changeEventFires when people commit a change, like when they leave the textarea

Both come straight from the <textarea>, like every other native event, such as focus and blur, and a binding to value listens to them for you. The Textarea has no slots.

We also export TextareaProps, which comes in handy for wrappers and data-driven forms:

import type { TextareaProps } from "@/components/Textarea";

const fields: (TextareaProps & { name: string; label: string })[] = [
  { name: "bio", label: "Bio", limit: 160 },
  { name: "address", label: "Street address", rows: 3 },
];

Composable

The Textarea renders two elements, so it splits what you pass between them, just like the Input. Classes and styles go on the root, so a class can set its variables and its width. Everything else lands on the <textarea>, including id, name, placeholder, required, readonly, autocomplete, spellcheck, dir, ARIA attributes and event listeners. A ref to the Textarea reaches the <textarea> too, since that's what form libraries and focus() need.

Forms

The <textarea> is a real form control, so the Textarea works with your forms however you build them. You can bind its value, as above, or leave it to the browser and read it from the form when it's sent, by its name. That also means the form works before your JavaScript loads.

import type { FormEvent } from "react";

export function Support() {
  function send(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    api.send(data.get("subject"), data.get("message"));
  }

  return (
    <form onSubmit={send}>
      <label htmlFor="subject">Subject</label>
      <Input id="subject" name="subject" />
      <label htmlFor="message">Message</label>
      <Textarea id="message" name="message" rows={5} />
      <Button type="submit" color="primary">Send</Button>
    </form>
  );
}

Form libraries work the same way. They register the <textarea> itself, through its ref or its attributes and listeners, which is why the Textarea puts them all on the <textarea>.

With other components

  • The Input takes a single line, and the Textarea several, with the same sizes, padding, border and states, so the two sit side by side in a form.
  • The Field, on our Roadmap, will render the label, hint and error message around a Textarea, give it an id, and set its aria-describedby and aria-invalid for you, keeping the count after them.
  • A Button after the textarea sends the form. It's type="submit", since Enter in a textarea starts a new line rather than submitting.
  • The count announces itself through the same announce as the Button's loading state, so the whole page shares one live region.

Controllable

The Textarea doesn't keep any state you can't reach. The value is yours to bind, or to leave to the browser, and every other state is a prop or an attribute that you control:

StateHow you control it
Valuevalue, bound to your state, or left to the browser
Heightrows, autoGrow and maxRows
Disableddisabled
Read-onlyreadonly, which lands on the <textarea>
Invalidaria-invalid, set while its error message shows, or by the Textarea over its limit
FocusA ref to its <textarea>, and focus()

Agentic Experience (AX)

Finally, let's look at how agents read, fill in and check a textarea. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against.

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names, values and states. This is how they see the states example on this page:

- textbox "Message": Could we move our call to Friday?
- textbox "Notes" [disabled]: Call back after 3pm
- textbox "Terms": Payment is due within 30 days.
- textbox "Reason for return" [invalid]

A textarea without a label is a textbox with no name, so an agent can't tell a message from a delivery note, any more than a screen reader user can. With a role and a name, an agent can find the textarea and fill it in, new lines and all:

await page.getByRole("textbox", { name: "Message" }).fill("Dear Ada,\nThank you for your note.");

A textarea and an input share the textbox role. Screen readers tell them apart by aria-multiline, which a <textarea> has without you setting it, and NVDA, for one, says "multi line" when it gets focus.

Described

Here's the whole contract in one block, for agents to read before they build or review a textarea:

component: Textarea
summary: Takes text over several lines, like a comment, a message or a description. Its label names it.
standard: https://opencomponents.dev/docs/components/textarea
element: textarea # inside a div, which holds the count and takes class and style
role: textbox # multi-line, which the <textarea> implies
props:
  size: { type: [xs, sm, md, lg, xl], default: md }
  rows: { type: number, default: 3 } # the lines it shows, and is never shorter than
  autoGrow: { type: boolean, default: false } # grows with its text, and can't be resized
  maxRows: { type: number } # with autoGrow. Without it, it keeps growing
  limit: { type: number } # shows a count, and never cuts people off
  countLabel: { type: "(remaining: number) => string" } # negative over the limit
  disabled: { type: boolean, default: false }
  value: { type: string } # when it's bound. Without it, the textarea keeps its own
events:
  input: Event, from the <textarea>. Fires as people type.
  change: Event, from the <textarea>. Fires when people commit a change, like when they leave it.
states:
  hover: ":hover, in @media (hover: hover)"
  focus: ":focus-visible"
  disabled: ":disabled"
  read-only: "[readonly]"
  invalid: "[aria-invalid=true], never :invalid. Set by the Textarea over its limit too"
  over-limit: "[data-slot=count][data-over-limit]"
keyboard:
  Tab: Moves focus to and from the textarea.
  Enter: Starts a new line. Never submits its form.
parts: [control, count] # data-slot, in this order. The control is the <textarea>
tokens:
  theme: ["--color--neutral-text", "--color--error-text", "--color--focus"]
  variables: [--textarea--height, --textarea--padding, --textarea--padding-block, --textarea--font-size, --textarea--radius, --textarea--border-color, --textarea--min-height, --textarea--max-height, --textarea--count--color, --textarea--rows, --textarea--max-rows]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/textarea#checklist

It's also published on its own at /raw/docs/components/textarea.yaml, with every rule from the checklist in place of the rules link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like textarea/no-truncation, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's a bio with a limit:

<Textarea id="bio" limit="160" aria-describedby="bio-hint" />

And here's what it renders on the server, before anyone has written anything:

<div class="textarea" data-size="md" data-rows="3" data-limit="160">
  <textarea
    data-slot="control"
    rows="3"
    id="bio"
    aria-describedby="bio-hint bio-count"
  ></textarea>
  <span data-slot="count" id="bio-count">160 characters left</span>
</div>
  • The size and the rows are mirrored as data-size and data-rows, which are always there, and autoGrow, maxRows and limit as data-auto-grow, data-max-rows and data-limit, when they're set. rows is also set on the <textarea>.
  • The styles turn data-rows and data-max-rows into the minimum and maximum heights, with typed attr(), so they hold as soon as the page loads, before any script runs. Browsers that can't read a number from an attribute yet get them once the script runs, which sets them as --textarea--rows and --textarea--max-rows on the root through the CSSOM. Until then, the rows attribute sets the height, except on an auto-growing textarea in a browser with field-sizing, which ignores rows, so it starts at the height of its text, with no maximum. A strict Content Security Policy allows that, unlike a style attribute, so your markup never needs one.
  • Parts are marked with data-slot and always come in the same order: control, then count. Only the control is always there.
  • States change attributes and text, never elements. The <textarea> is never replaced, so focus, the caret, what people have written and the screen reader's position all survive every state change, going over the limit included.
  • There's nothing random or generated, like IDs or timestamps, so the server renders the same markup every time, and so does every snapshot. The count's id is the textarea's, with -count on the end.
  • Browsers also differ in how an auto-growing textarea grows. Those without field-sizing get its height from a script, as a height in the <textarea>'s style.

Verifiable

Every rule in the checklist can be checked, and about half of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in Textarea.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them.

In the browser

Unit tests run without a layout, so they can't check a textarea's height. In the browser, you can check how it grows and how far it resizes with visual regression tests, run axe to check labels and contrast, and fill in the form the way an agent would, to check what happens past the limit:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("the contact form keeps a long message, and says it's too long", async ({ page }) => {
  await page.goto("/contact");

  const message = page.getByRole("textbox", { name: "Message" });
  await message.fill("a".repeat(210));
  await message.press("Enter");

  await expect(message).toHaveValue(`${"a".repeat(210)}\n`);
  await expect(message).toHaveAttribute("aria-invalid", "true");
  await expect(message).toHaveAccessibleDescription(/11 characters too many/);
  await expect(page).toHaveURL("/contact");

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);
});

Some things still need a human, so try your forms with the keyboard alone, with a screen reader, on a phone, where you can check the keyboard, zoom and the resize handle, and at 200% zoom, with forced colours and more contrast turned on. You can emulate the last two in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as a textarea renders without a label, or with a limit but no id for its count to point back at.

Prompts

You can point your agent straight at this page. To build a textarea, you could use:

Build a Textarea component for our React design system that meets the Open Components
Textarea standard: https://opencomponents.dev/raw/docs/components/textarea.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then
check your work against every rule in its checklist with a Component or Both scope, and
cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Textarea against the Open Components Textarea contract:
https://opencomponents.dev/raw/docs/components/textarea.yaml

For each rule with a component or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

And to review how a screen uses its textareas:

Review how our contact form uses its textareas against the Open Components Textarea
contract: https://opencomponents.dev/raw/docs/components/textarea.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and the
evidence from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A textarea meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Textarea itself, so they're the ones to check when you build or review one. Usage rules are met by the forms that use it, like giving every textarea a label, so they're the ones to check when you review a screen. Both rules need the two to work together, like Enter, which the Textarea leaves to the browser, as long as the page doesn't make it send the form.

The basis says where a rule comes from. About half the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from HTML. The rest are Open Components rules, which are our own, like counting past a limit rather than cutting people off. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level AAA, like the 44px height on touch screens, and a rule that asks for more than its criterion says beyond, like using a native <textarea>, where WCAG would let you rebuild one by hand.

Unit tests run without a layout, so the rules about height, resizing and growing are checked in the browser, at 200% zoom or with visual regression tests.

UI rules

RuleLevelScopeRequirementBasisCheck
textarea/contrastMustComponentThe text, the placeholder and the count have a contrast of 4.5:1, and the focus ring has 3:1 against the pageWCAG 1.4.3, 1.4.11 (AA)axe color-contrast, contrast checker
textarea/boundary-contrastMustComponentThe border has a contrast of 3:1 against the page, so an empty textarea stays visibleWCAG 1.4.11 (AA)Contrast checker
textarea/focus-visibleMustComponentFocus shows an outline at least 2px thick around the field, outside its borderWCAG 2.4.7 (AA), 2.4.13 (AAA)Keyboard
textarea/target-sizeMustComponentThe field is at least 24px tall, and at least 44px tall on touch screensWCAG 2.5.8 (AA), 2.5.5 (AAA)Emulation
textarea/no-zoom-on-focusShouldComponentOn touch screens, the text is at least 16px, so Safari on iOS doesn't zoom in when the textarea gets focusOpen ComponentsEmulation
textarea/min-rowsMustComponentIt's never shorter than its rows, whether people resize it or it grows on its ownOpen ComponentsVisual regression test
textarea/resizableMustComponentIts height is a minimum, in lines of its own line height plus padding in rem, so it grows with its text instead of clipping itOpen Components, beyond WCAG 1.4.4, 1.4.12 (AA)200% zoom
textarea/block-resizeShouldComponentPeople resize it on the block axis only, and resizing is only turned off when it grows on its own or is disabledOpen ComponentsVisual regression test
textarea/auto-growShouldComponentWith autoGrow, it grows with its text up to maxRows, then scrolls, with field-sizing: content or a script where that's not supportedOpen ComponentsVisual regression test, 200% zoom
textarea/stable-sizeMustComponentNo state changes the field's size or positionOpen ComponentsVisual regression test
textarea/state-selectorsShouldComponentStates are styled from the attributes on the <textarea> that expose them, and never from :invalid or :user-invalidOpen ComponentsReview
textarea/hover-capableShouldComponentHover styles only apply inside @media (hover: hover)Open ComponentsReview
textarea/forced-colorsMustComponentThe border, and the focus, disabled and read-only states, stay visible in forced colours modeOpen ComponentsEmulation
textarea/more-contrastShouldComponentThe border is drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation
textarea/fits-textShouldUsageIts rows fit the text it expects, and it's never wider than its columnOpen Components, beyond WCAG 1.4.10 (AA)Review

UX rules

RuleLevelScopeRequirementBasisCheck
textarea/native-elementMustComponentIt's a native <textarea>, and never a contenteditable element or a role="textbox"Open Components, beyond WCAG 2.1.1, 4.1.2 (A)Unit test
textarea/visible-labelMustUsageEvery textarea has a visible <label>, linked with for, rather than a placeholder aloneWCAG 1.3.1, 3.3.2, 4.1.2 (A)axe label
textarea/label-in-nameMustUsageAn aria-label or aria-labelledby includes the visible label, ideally at the startWCAG 2.5.3 (A)axe label-content-name-mismatch
textarea/describedShouldUsageHints, like what to include, are linked with aria-describedby, and a placeholder only ever shows a short exampleWCAG 1.3.1 (A), Open ComponentsScreen reader
textarea/keyboardMustComponentTab moves focus to and from it, and the browser's text editing keys work as they do in any textareaWCAG 2.1.1, 2.1.2 (A)Unit test
textarea/enter-new-lineMustBothEnter starts a new line and never submits the form, which has a submit buttonHTMLUnit test
textarea/allow-pasteMustBothPasting and autofill are never blockedOpen ComponentsUnit test, review
textarea/no-truncationMustBothA limit is counted rather than enforced with maxlength, so people can type and paste past itOpen ComponentsUnit test
textarea/limit-in-hintShouldUsageA limit is stated in the hint, linked with aria-describedby, before people start writingWCAG 3.3.2 (A)Screen reader
textarea/countShouldComponentThe count says how many characters are left, or how many too many, describes the textarea, and over the limit sets aria-invalid="true"WCAG 3.3.1 (A), Open ComponentsUnit test
textarea/announce-countShouldComponentThe count is announced through one polite live region once people pause near the limit, and never on every keystrokeWCAG 4.1.3 (AA), Open ComponentsUnit test, screen reader
textarea/input-purposeMustUsageA textarea that asks for the person's own address has autocomplete="street-address"WCAG 1.3.5 (AA)axe autocomplete-valid, review
textarea/error-messageMustUsageAn invalid textarea has an error message, in text and linked with aria-describedby, that says what's wrong and how to fix itWCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA)Screen reader
textarea/invalid-stateMustBothA textarea sets aria-invalid="true" while its error message shows, and only looks invalid when it doesWCAG 4.1.2 (A), Open ComponentsUnit test
textarea/validate-lateShouldUsageErrors show when people submit or leave the textarea, never while they're first writing, and clear as soon as the text is fixedOpen ComponentsReview
textarea/focus-first-errorShouldUsageWhen a submit fails, focus moves to the first invalid field, or to a summary of the errors, once the error messages are in the pageOpen ComponentsKeyboard, screen reader
textarea/disabled-inertMustComponentA disabled textarea can't be focused, edited or submittedHTMLUnit test
textarea/read-only-valuesShouldUsageText people need to read but can't change is read-only rather than disabled, so they can focus it, scroll it and copy it, and its form sends itOpen ComponentsReview
textarea/keep-focusMustUsageA textarea is never disabled while it has focus, so while its form is sent, it's read-only insteadOpen ComponentsKeyboard
textarea/focus-not-obscuredMustUsageSticky headers and footers never cover a focused textarea, for example thanks to scroll-paddingWCAG 2.4.11 (AA), 2.4.12 (AAA)Keyboard
textarea/mirrors-in-rtlShouldBothIt uses logical properties, and a textarea for free text has dir="auto"Open ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
textarea/shared-vocabularyMustComponentIt uses the shared names size, disabled and value, with the Input's sizesOpen ComponentsReview
textarea/typedMustComponentProps and events are typed, with unions rather than string, and rows, maxRows and limit take numbersOpen ComponentsType check
textarea/control-attributesMustComponentAttributes, listeners and refs land on the <textarea>, while classes and styles land on the rootOpen ComponentsUnit test
textarea/form-controlMustComponentIts form sends its text under its name, whether the value is bound or notHTMLUnit test
textarea/controllableShouldComponentIts value can be bound or left to the browser, and every other state is a prop or an attributeOpen ComponentsUnit test

AX rules

RuleLevelScopeRequirementBasisCheck
textarea/role-and-nameMustComponentIt can be found by its role and label alone, with getByRole("textbox", { name })WCAG 4.1.2 (A)Unit test
textarea/deterministic-domMustComponentThe same props render the same markup, with no generated IDs, and states change attributes, never elementsOpen ComponentsUnit test
textarea/data-attributesShouldComponentThe size, rows, autoGrow, maxRows and limit are mirrored as data-size, data-rows, data-auto-grow, data-max-rows and data-limit, with data-max-rows only alongside autoGrow, and parts are marked with data-slotOpen ComponentsUnit test
textarea/dev-warningsShouldComponentIt warns during development when a textarea has no label, or a limit without an idOpen ComponentsUnit test

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It announces its count with the Button's announce.ts, which every component that announces something shares, so you only need one copy of it, and it reads the same theme tokens, so copy the Button's tokens.css into your theme too.

The tests are in Textarea.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useAttrs, useTemplateRef, watch, type TextareaHTMLAttributes } from "vue";
import { announce, prepareAnnouncer } from "../button/announce";

export interface TextareaProps {
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  /** How many lines it shows. It's never shorter, even when people resize it. */
  rows?: number;
  /** Grows with its text, up to `maxRows`, rather than scrolling. People can't resize it then. */
  autoGrow?: boolean;
  /** How many lines it grows to with `autoGrow`, before it scrolls. Without it, it keeps growing. */
  maxRows?: number;
  /** The most characters it takes. It shows a count, and lets people go over, so nothing they paste gets cut off. */
  limit?: number;
  /** The count's text, for a number of characters left, which is negative over the limit. */
  countLabel?: (remaining: number) => string;
  disabled?: boolean;
}

// Attributes go on the <textarea> rather than on the root, so they're declared below.
defineOptions({ inheritAttrs: false });

const {
  size = "md",
  rows = 3,
  autoGrow = false,
  maxRows,
  limit,
  countLabel = (remaining: number) => {
    const count = Math.abs(remaining);
    const characters = count === 1 ? "character" : "characters";
    return remaining < 0 ? `${count} ${characters} too many` : `${count} ${characters} left`;
  },
  disabled = false,
} = defineProps<TextareaProps>();

/** The value, which `v-model` binds. Without it, the textarea keeps its own, and its form submits it. */
const model = defineModel<string>();

const attrs = useAttrs();
// Classes and styles go on the root, so a class can set its variables. Everything
// else goes on the <textarea>: `id`, `name`, `placeholder`, ARIA and listeners.
// Attrs aren't reactive, so these run on every render.
const onRoot = (key: string) => key === "class" || key === "style";
const rootAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => onRoot(key)));
const controlAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => !onRoot(key)));

const root = useTemplateRef<HTMLElement>("root");
const textarea = useTemplateRef<HTMLTextAreaElement>("textarea");

// What people have typed, so the count works without `v-model` too.
const typed = ref(typeof attrs.value === "string" ? attrs.value : "");
const value = computed(() => model.value ?? typed.value);

// Code points rather than UTF-16 code units, which is how many databases count a
// length limit, like PostgreSQL's varchar(n). An emoji made of several, like a
// family, counts as several.
const remaining = computed(() => (limit === undefined ? 0 : limit - [...value.value].length));
const over = computed(() => remaining.value < 0);
const countText = computed(() => countLabel(remaining.value));
// The count is named after the textarea, so it needs its `id` to describe it.
const countId = computed(() => (limit !== undefined && attrs.id ? `${attrs.id}-count` : undefined));
// Over the limit, it's invalid, whatever the page says.
const invalid = () => (over.value ? "true" : (attrs["aria-invalid"] as TextareaHTMLAttributes["aria-invalid"]));
const describedBy = () => [attrs["aria-describedby"], countId.value].filter(Boolean).join(" ") || undefined;

// `rows` and `maxRows` as variables, in lines, which the styles turn into a minimum
// and a maximum height. Browsers with typed attr() read them from `data-rows` and
// `data-max-rows` before any script runs. The others can't read a number from an
// attribute yet, so they're also set through the CSSOM, which a strict Content
// Security Policy allows, unlike a style attribute. They run after every render,
// since a change to your own `style` replaces the root's.
function setRows() {
  const el = root.value;
  if (!el) return;
  el.style.setProperty("--textarea--rows", String(rows));
  if (autoGrow && maxRows !== undefined) el.style.setProperty("--textarea--max-rows", String(maxRows));
  else el.style.removeProperty("--textarea--max-rows");
}
onMounted(setRows);
onUpdated(setRows);

// Browsers with `field-sizing: content` grow the textarea themselves. In the
// others, it's set to the height of its text, which max-height caps.
function grow() {
  const el = textarea.value;
  if (!autoGrow || !el || (typeof CSS !== "undefined" && CSS.supports?.("field-sizing", "content"))) return;
  el.style.height = "auto";
  el.style.height = `${el.scrollHeight + el.offsetHeight - el.clientHeight}px`;
}
watch(value, () => nextTick(grow));

// Screen readers hear the count once people pause near the limit, rather than on
// every keystroke, which would talk over what they're typing.
const NEAR_LIMIT = 0.8;
const PAUSE = 1000;
let timer: ReturnType<typeof setTimeout> | undefined;
let announced = "";
function announceCount() {
  if (limit === undefined) return;
  clearTimeout(timer);
  timer = setTimeout(() => {
    const near = limit - remaining.value >= limit * NEAR_LIMIT;
    if (!near || countText.value === announced) return;
    announced = countText.value;
    announce(countText.value);
  }, PAUSE);
}

// The template shows the model, or the `value` attribute while there's none, so a
// textarea without `v-model`, like a read-only one, can still start with a value.
function onInput(event: Event) {
  const text = (event.target as HTMLTextAreaElement).value;
  typed.value = text;
  model.value = text;
  announceCount();
}

// A form reset puts back the textarea's first value without an input event, so
// the count reads it again once the reset is done. That's after the event and,
// for a click on a reset button, after the microtasks that follow it too.
let form: HTMLFormElement | null = null;
function onReset() {
  setTimeout(() => {
    const text = textarea.value?.value ?? "";
    typed.value = text;
    if (model.value !== undefined) model.value = text;
  });
}

// A ref to the Textarea gets you the <textarea>, through `textarea`.
defineExpose({ textarea });

onMounted(() => {
  // The live region has to be in the page before anything is announced.
  if (limit !== undefined) prepareAnnouncer();
  grow();
  form = textarea.value?.form ?? null;
  form?.addEventListener("reset", onReset);

  if (import.meta.env.DEV) {
    const el = textarea.value;
    const named = el?.labels?.length || el?.hasAttribute("aria-label") || el?.hasAttribute("aria-labelledby");
    if (el && !named) {
      console.warn("[Textarea] has no accessible name: give it a <label for>.", el);
    }
    if (el && limit !== undefined && !el.id) {
      console.warn("[Textarea] has a limit but no id: give it one, so its count can describe it.", el);
    }
  }
});

onBeforeUnmount(() => {
  clearTimeout(timer);
  form?.removeEventListener("reset", onReset);
});
</script>

<template>
  <div
    ref="root"
    class="textarea"
    v-bind="rootAttrs()"
    :data-size="size"
    :data-rows="rows"
    :data-auto-grow="autoGrow ? '' : undefined"
    :data-max-rows="autoGrow ? maxRows : undefined"
    :data-limit="limit"
  >
    <textarea
      ref="textarea"
      data-slot="control"
      :rows="rows"
      :disabled="disabled"
      v-bind="controlAttrs()"
      :value="model ?? ($attrs.value as string | undefined)"
      :aria-invalid="invalid()"
      :aria-describedby="describedBy()"
      @input="onInput"
    />
    <span v-if="limit !== undefined" data-slot="count" :id="countId" :data-over-limit="over ? '' : undefined">
      {{ countText }}
    </span>
  </div>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The textarea's styles go in `components`,
   so they win over the resets in `base`, and give way to utilities and to any
   style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

@layer components {
  .textarea {
    /* Set per size. The Input's values, so a textarea of one row is as tall as an
       input, and the two line up in a form. */
    --textarea--height: 2.25rem;
    --textarea--padding: 0.75rem;
    --textarea--font-size: 0.875rem;
    --textarea--radius: 0.5rem;
    /* The text colour at 55%, which has 3:1 against the page, so people can see where to type. */
    --textarea--border-color: color-mix(in srgb, currentColor 55%, transparent);
    /* The text colour at 70%, which has 4.5:1 against the page, like the placeholder. */
    --textarea--count--color: color-mix(in srgb, currentColor 70%, transparent);
    /* What's left of one row's height once its line of text is in, above and below it.
       `lh` is the textarea's line height, so it follows larger text and wider lines. */
    --textarea--padding-block: max(0.125rem, (var(--textarea--height) - 1lh) / 2 - 1px);
    /* `rows` lines, with their padding and border. Resizing and growing stop there.
       The rows come from `data-rows`, in browsers with typed attr(), so it holds before
       the script runs. In the others, it has no value until then, and the `rows`
       attribute sets the height, except with auto-grow, whose field-sizing ignores
       `rows`, so it starts at the height of its text, with no maximum. */
    --textarea--min-height: calc(
      var(--textarea--rows, attr(data-rows type(<number>))) * 1lh + 2 * var(--textarea--padding-block) + 2px
    );
    /* Only set with `maxRows`. Without it, max-height has no value, which is none. */
    --textarea--max-height: calc(
      var(--textarea--max-rows, attr(data-max-rows type(<number>))) * 1lh + 2 * var(--textarea--padding-block) + 2px
    );

    display: flex;
    flex-direction: column;
    /* In a flex row, it can get narrower than a <textarea> is on its own, rather than overflow. */
    min-width: 0;
    color: var(--color--neutral-text);
    font-family: inherit;
    font-size: var(--textarea--font-size);
  }

  /* Sizes */
  .textarea[data-size="xs"] {
    --textarea--height: 1.5rem;
    --textarea--padding: 0.5rem;
    --textarea--font-size: 0.75rem;
    --textarea--radius: 0.375rem;
  }
  .textarea[data-size="sm"] {
    --textarea--height: 2rem;
    --textarea--padding: 0.625rem;
    --textarea--radius: 0.375rem;
  }
  .textarea[data-size="lg"] {
    --textarea--height: 2.5rem;
    --textarea--padding: 0.875rem;
    --textarea--font-size: 1rem;
  }
  .textarea[data-size="xl"] {
    --textarea--height: 3rem;
    --textarea--padding: 1rem;
    --textarea--font-size: 1rem;
    --textarea--radius: 0.75rem;
  }

  /* Parts. The control draws the field itself, since its resize handle and its
     scrollbar belong to it. */
  .textarea > [data-slot="control"] {
    display: block;
    box-sizing: border-box;
    width: 100%;
    /* min-height rather than height, so it's never shorter than its rows, and grows with larger text. */
    min-height: var(--textarea--min-height);
    max-height: var(--textarea--max-height);
    margin: 0;
    padding: var(--textarea--padding-block) var(--textarea--padding);
    border: 1px solid var(--textarea--border-color);
    border-radius: var(--textarea--radius);
    background-color: transparent;
    color: inherit;
    font: inherit;
    /* Taller than the Input's, since people read their text back over several lines. */
    line-height: 1.5;
    letter-spacing: inherit;
    /* Only taller or shorter, so it never outgrows its column. `block` follows the
       writing mode, and `vertical` is for browsers that don't know it. */
    resize: vertical;
    resize: block;
  }
  .textarea > [data-slot="control"]::placeholder {
    /* The text colour at 70%, which has 4.5:1 against the page. Firefox lowers its opacity on its own. */
    color: color-mix(in srgb, currentColor 70%, transparent);
    opacity: 1;
  }
  /* It grows with its text, so there's nothing for people to resize. Browsers without
     field-sizing get their height from the script instead. */
  .textarea[data-auto-grow] > [data-slot="control"] {
    resize: none;
    field-sizing: content;
  }
  .textarea > [data-slot="count"] {
    margin-block-start: 0.375rem;
    color: var(--textarea--count--color);
    line-height: 1.25;
    text-align: end;
    /* So the count doesn't jiggle as its digits change. */
    font-variant-numeric: tabular-nums;
  }
  .textarea > [data-slot="count"][data-over-limit] {
    color: var(--color--error-text);
  }

  /* States. Each one reads the attribute on the <textarea> that exposes it, so what
     people see and what assistive technologies report can't drift apart. :invalid
     isn't one of them: it matches a required textarea before anyone has typed in it.
     The variables are set on the root, as every other one is, so a class of yours
     still sets them in every state. Hover is in :where(), so invalid always wins. */
  @media (hover: hover) {
    .textarea:where(:has(> [data-slot="control"]:hover:not(:disabled, [readonly]))) {
      --textarea--border-color: color-mix(in srgb, currentColor 80%, transparent);
    }
  }
  .textarea > [data-slot="control"]:focus-visible {
    outline: 2px solid var(--color--focus);
    outline-offset: 2px;
  }
  .textarea > [data-slot="control"][readonly] {
    /* Dashed, so it reads as something you can't change, while the text keeps its contrast. */
    border-style: dashed;
    cursor: default;
  }
  .textarea:has(> [data-slot="control"][aria-invalid="true"]) {
    --textarea--border-color: var(--color--error-text);
  }
  .textarea > [data-slot="control"][aria-invalid="true"] {
    /* A second pixel of border, drawn inside, so the field doesn't grow. */
    box-shadow: inset 0 0 0 1px var(--textarea--border-color);
  }
  .textarea:has(> [data-slot="control"]:disabled) {
    opacity: 0.5;
  }
  .textarea > [data-slot="control"]:disabled {
    cursor: not-allowed;
    /* Nobody can write in it, so there's nothing to make room for. */
    resize: none;
  }

  /* On touch screens, the text is at least 16px, or Safari on iOS zooms in when the
     textarea gets focus, and the field is at least 44px tall, whatever its rows. */
  @media (any-pointer: coarse) {
    .textarea {
      font-size: max(var(--textarea--font-size), 1rem);
    }
    .textarea > [data-slot="control"] {
      min-height: max(var(--textarea--min-height), 2.75rem);
    }
  }

  /* With more contrast, the border is drawn in the text colour at full strength. */
  @media (prefers-contrast: more) {
    .textarea {
      --textarea--border-color: currentColor;
    }
  }

  /* Forced colors mode keeps borders but drops shadows, so mark states with system colours. */
  @media (forced-colors: active) {
    .textarea > [data-slot="control"]:focus-visible {
      outline-color: Highlight;
    }
    .textarea:has(> [data-slot="control"]:disabled) {
      color: GrayText;
      opacity: 1;
    }
    .textarea > [data-slot="control"]:disabled {
      border-color: GrayText;
    }
  }
}
</style>

FAQ

Textarea or something else?

A Textarea takes plain text over several lines. For anything else, another component will do a better job:

When people need to…Use
Type a single line of text, like a name or a subjectInput
Write several lines of plain text, like a message or a noteTextarea
Write a short answer that might run long, like a titleTextarea, with rows="1" and autoGrow
Format what they write, with bold text, lists or linksA rich text editor
Write code, with indenting and highlightingA code editor

Sources

Copyright © 2026