Textarea

Agentic Experience (AX)

How agents read, fill in and check a textarea, from its accessibility tree to its contract, its DOM and its tests.

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.

AX Checklist

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
Copyright © 2026