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
- Container — The root
<div>, which carries the size and holds the count below the field. - Control — The
<textarea>itself, which draws the field, holds the text and takes focus. Its label names it. - Resize handle — The browser's own, in the corner, which people drag to make the field taller or shorter.
- Count — Optional. How many characters are left, or how many too many, which describes the control.
- 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 */}
| Size | One row | Text | Line height | Padding | Radius |
|---|---|---|---|---|---|
xs | 24px | 12px | 18px | 8px | 6px |
sm | 32px | 14px | 21px | 10px | 6px |
md | 36px | 14px | 21px | 12px | 8px |
lg | 40px | 16px | 24px | 14px | 8px |
xl | 48px | 16px | 24px | 16px | 12px |
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
rowsfor 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: contentwhere 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
<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 inbio-count, so give every textarea with a limit anid, 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.
Enter a reason for returning the item
<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>
| State | Selector | Recommended look |
|---|---|---|
| Hover | :hover, inside @media (hover: hover) | The border at 80% of the text colour, up from 55% |
| Focus | :focus-visible | A 2px outline, 2px from the edge, in --color--focus |
| Disabled | :disabled | 50% 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:
| Token | Used for |
|---|---|
--color--neutral-text | The text, and mixed into the border, the placeholder and the count |
--color--error-text | The border of an invalid textarea, and the count over its limit |
--color--focus | The 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:
| Variable | What it sets |
|---|---|
--textarea--height | The height of one row, the same as the Input's, which sets the padding |
--textarea--padding | The padding before and after the text |
--textarea--padding-block | The padding above and below the text, from the height of one row |
--textarea--font-size | The text, and the count |
--textarea--radius | The corners |
--textarea--border-color | The border, which hover, invalid and more contrast change |
--textarea--min-height | The height of rows lines, which resizing and growing never go below |
--textarea--max-height | The height of maxRows lines, which growing stops at |
--textarea--count--color | The count, until it's over the limit |
--textarea--rows, --textarea--max-rows | rows 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:
import styles from "./Feedback.module.css";
export function Feedback() {
return (
<>
<label htmlFor="feedback">Feedback</label>
<Textarea id="feedback" className={styles.soft} />
</>
);
}
.soft {
--textarea--radius: 1rem;
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
textarea/contrast | Must | Component | The text, the placeholder and the count have a contrast of 4.5:1, and the focus ring has 3:1 against the page | WCAG 1.4.3, 1.4.11 (AA) | axe color-contrast, contrast checker |
textarea/boundary-contrast | Must | Component | The border has a contrast of 3:1 against the page, so an empty textarea stays visible | WCAG 1.4.11 (AA) | Contrast checker |
textarea/focus-visible | Must | Component | Focus shows an outline at least 2px thick around the field, outside its border | WCAG 2.4.7 (AA), 2.4.13 (AAA) | Keyboard |
textarea/target-size | Must | Component | The field is at least 24px tall, and at least 44px tall on touch screens | WCAG 2.5.8 (AA), 2.5.5 (AAA) | Emulation |
textarea/no-zoom-on-focus | Should | Component | On touch screens, the text is at least 16px, so Safari on iOS doesn't zoom in when the textarea gets focus | Open Components | Emulation |
textarea/min-rows | Must | Component | It's never shorter than its rows, whether people resize it or it grows on its own | Open Components | Visual regression test |
textarea/resizable | Must | Component | Its height is a minimum, in lines of its own line height plus padding in rem, so it grows with its text instead of clipping it | Open Components, beyond WCAG 1.4.4, 1.4.12 (AA) | 200% zoom |
textarea/block-resize | Should | Component | People resize it on the block axis only, and resizing is only turned off when it grows on its own or is disabled | Open Components | Visual regression test |
textarea/auto-grow | Should | Component | With autoGrow, it grows with its text up to maxRows, then scrolls, with field-sizing: content or a script where that's not supported | Open Components | Visual regression test, 200% zoom |
textarea/stable-size | Must | Component | No state changes the field's size or position | Open Components | Visual regression test |
textarea/state-selectors | Should | Component | States are styled from the attributes on the <textarea> that expose them, and never from :invalid or :user-invalid | Open Components | Review |
textarea/hover-capable | Should | Component | Hover styles only apply inside @media (hover: hover) | Open Components | Review |
textarea/forced-colors | Must | Component | The border, and the focus, disabled and read-only states, stay visible in forced colours mode | Open Components | Emulation |
textarea/more-contrast | Should | Component | The border is drawn at full strength when prefers-contrast: more is set | Open Components | Emulation |
textarea/fits-text | Should | Usage | Its rows fit the text it expects, and it's never wider than its column | Open Components, beyond WCAG 1.4.10 (AA) | Review |
FAQ
Overview
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.
User Experience
How a textarea behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.