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-sizeanddata-rows, which are always there, andautoGrow,maxRowsandlimitasdata-auto-grow,data-max-rowsanddata-limit, when they're set.rowsis also set on the<textarea>. - The styles turn
data-rowsanddata-max-rowsinto the minimum and maximum heights, with typedattr(), 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--rowsand--textarea--max-rowson the root through the CSSOM. Until then, therowsattribute sets the height, except on an auto-growing textarea in a browser withfield-sizing, which ignoresrows, so it starts at the height of its text, with no maximum. A strict Content Security Policy allows that, unlike astyleattribute, so your markup never needs one. - Parts are marked with
data-slotand always come in the same order:control, thencount. 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
idis the textarea's, with-counton the end. - Browsers also differ in how an auto-growing textarea grows. Those without
field-sizingget its height from a script, as aheightin the<textarea>'sstyle.
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
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
textarea/role-and-name | Must | Component | It can be found by its role and label alone, with getByRole("textbox", { name }) | WCAG 4.1.2 (A) | Unit test |
textarea/deterministic-dom | Must | Component | The same props render the same markup, with no generated IDs, and states change attributes, never elements | Open Components | Unit test |
textarea/data-attributes | Should | Component | The 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-slot | Open Components | Unit test |
textarea/dev-warnings | Should | Component | It warns during development when a textarea has no label, or a limit without an id | Open Components | Unit test |