Textarea
Multi-line free text. Autosize, counters, a resize handle the user keeps — and an Enter key that must not submit the form.
Also called Multiline Input, Comment Box, Text Area — in this system all of them are Textarea.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Enter is a newline
The comment box every product needs. Enter breaks the line; ⌘↵ sends; the shortcut is printed next to the button so it is discoverable rather than folkloric.
Height sets the expectation
The same question at two rows and at ten. The taller field does not collect better answers — it collects more apologetic ones.
Autosize with a ceiling
The field grows with the content up to a maximum, then scrolls. Without the ceiling, a pasted wall of text pushes the submit button off the screen.
Counters and over-limit
The counter is visible from the first character. Going over is an error state, not a hard block — cutting the user off mid-word loses the sentence they were finishing.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Visible to everyone with access to this project.
Label, static description, the field itself, and a counter that has been visible since the first character.
- Initial rows4 rows ≈ 96px
The promise about the expected answer. Two rows for a sentence, four for a paragraph. Ten rows makes an honest short answer feel inadequate.
- Padding10px 12px
Slightly more vertical padding than a single-line input, because the first line needs to sit off the top edge rather than be optically centred.
- Line height1.6
Looser than a single-line control. Multi-line text needs the leading to stay readable, and it is what makes the row count predict the height.
- Max height12 rows, then scroll
The ceiling on autosize. Without it, a pasted wall of text pushes the submit button below the fold and the user cannot find it.
- Resize handleVertical only, 16px
Never horizontal — a field wider than its container breaks the form layout. Disabled when autosize is on, since two things would be fighting for the height.
- Counter12px, top-right of the field
Visible from the first character. Appearing only near the limit means the user learns about it after they have written past it.
- MessageStacks under the description
It never replaces the description. Losing the instructions at the moment of failure is exactly backwards.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Field fill |
| --ds-border-interactive | — | Idle border |
| --ds-accent | — | Focus border |
| --ds-accent-subtle | — | Focus halo |
| --ds-danger-border | — | Error border |
| --ds-danger-text | — | Error message and over-limit counter |
| --ds-fg | — | Typed text |
| --ds-fg-muted | — | Placeholder, description, counter |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding | Field padding |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Field corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-body | Typed text and its leading |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Border and halo transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Type | Max width | When to use |
|---|---|---|---|---|---|---|
| Small | 2 rows ≈ 56px | 8px 10px | 8px | 13px | — | Inline comment boxes and dense forms. |
| Medium | 4 rows ≈ 96px | 10px 12px | 8px | 15px | — | The default. Descriptions and notes. |
| Large | 6 rows ≈ 152px | 12px 14px | 12px | 16px | — | The main content field on a page dedicated to writing. |
| Autosize ceiling | 12 rows | — | — | — | — | Grows with content to here, then scrolls. Non-negotiable — without it the submit button leaves the screen. |
| Measure | — | — | — | — | 36rem | About 70 characters per line. Wider and the eye loses the start of the next line. |
16 characters over the limit
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The idle border owes 3:1 — it is the only thing showing an input is there.
- Placeholder text owes 4.5:1 like any other text. The common 2.5:1 placeholder is a contrast failure people excuse because it is "just a hint".
- The error state changes the border, the message and the counter together, so it survives greyscale and High Contrast Mode.
- The resize handle is a control and owes 3:1 against the field fill.
Keyboard
| Enter | Inserts a newline. Always. |
| ⌘ / Ctrl + Enter | Submits, when the surrounding surface has a submit action. Must be printed next to the button. |
| Tab | Leaves the field. It must never insert a tab character — that traps keyboard users inside the textarea. |
| Esc | In a comment box, optionally discards the draft. Confirm first if anything has been typed. |
| ⌘ / Ctrl + A | Selects the field’s content only, never the page. |
Screen readers
- Announce the limit in the description, not only in the counter: "Up to 280 characters" is read once, at the point it is useful.
- Throttle counter announcements. Every keystroke is unusable; roughly every 20 characters, plus one at the limit and one when going over, is right.
- A textarea with a placeholder and no label announces as "edit text, blank" once typing starts. There must always be a real label or an aria-label.
Focus & touch
- The focus halo must be visible on all four sides, which means the field cannot sit flush against a container edge. On error, focus moves to the first invalid field and the message is announced — not just coloured.
- The on-screen keyboard covers the lower half of the screen, so a textarea near the bottom of a form must scroll into view on focus along with its submit button. Autosize matters more on touch than anywhere else: a two-row field on a phone shows almost nothing of what has been written. Set inputmode and the enterKeyHint so the keyboard offers a newline key rather than "Go".
| Attribute | Applied to | Notes |
|---|---|---|
| <label for> | The field | A real label element. aria-label is the fallback for a field with no visible label, such as a bare comment box. |
| aria-describedby | The field | Points at the description and, when present, the counter — so both are read after the label. |
| aria-invalid | The field | Set when the value is invalid, including over the character limit. |
| aria-errormessage | The field | Points at the message element, which must be rendered before it is referenced. |
| role="status" | The counter | Polite, and throttled to roughly every 20 characters. Announcing every keystroke is unusable. |
| maxlength | The field | Deliberately omitted when you want to allow going over. A hard maxlength swallows keystrokes with no explanation. |
Example usage
1import { Field, Textarea } from '@/ui/Input'23const MAX = 2804const over = value.length > MAX56<Field7 label="Deployment note"8 description="Visible to everyone with access to this project."9 // Visible from the first character, not only when the user is close.10 counter={{ value: value.length, max: MAX }}11 status={over ? 'error' : 'default'}12 message={over ? `${value.length - MAX} characters over the limit` : undefined}13>14 <Textarea15 rows={4}16 autoResize17 maxRows={12} // the ceiling is not optional18 value={value}19 onChange={(e) => setValue(e.target.value)}20 // No maxLength: we want the text through so we can explain it, rather21 // than swallowing keystrokes mid-word.22 onKeyDown={(e) => {23 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {24 e.preventDefault()25 submit()26 }27 }}28 />29</Field>3031// Autosize without layout thrash: reset, then read, then set. Reading32// scrollHeight before the reset gives you the previous height forever.33function autosize(el: HTMLTextAreaElement, maxRows = 12) {34 el.style.height = 'auto'35 const lineHeight = parseFloat(getComputedStyle(el).lineHeight)36 el.style.height = `${Math.min(el.scrollHeight, lineHeight * maxRows)}px`37}Framework-free HTML
<div class="ds-field">
<label for="note">Deployment note</label>
<p id="note-desc" class="ds-field__desc">
Visible to everyone with access to this project. Up to 280 characters.
</p>
<textarea
id="note"
rows="4"
aria-describedby="note-desc note-count"
aria-invalid="true"
aria-errormessage="note-err"
enterkeyhint="enter"
></textarea>
<!-- Throttled: announcing every keystroke is unusable. -->
<p id="note-count" role="status" aria-live="polite">296 of 280</p>
<!-- Stacks under the description. It never replaces it. -->
<p id="note-err" class="ds-field__error">16 characters over the limit</p>
</div>CSS
.ds-textarea {
inline-size: 100%;
max-inline-size: 36rem; /* ~70 characters — a readability cap */
min-block-size: calc(4 * 1.6em + 20px);
padding: 10px 12px;
border: 1px solid var(--ds-border-interactive);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
font: inherit;
line-height: 1.6; /* looser than a single-line control */
/* Vertical only. A field dragged wider than its container breaks the
form and can push the submit button off-screen. */
resize: vertical;
field-sizing: content; /* native autosize where supported */
max-block-size: calc(12 * 1.6em + 20px);
}
/* Two things fighting over the height is one too many. */
.ds-textarea[data-autoresize='true'] { resize: none; }
.ds-textarea:focus-visible {
border-color: var(--ds-accent);
box-shadow: 0 0 0 3px var(--ds-accent-subtle);
outline: none;
}
.ds-textarea[aria-invalid='true'] { border-color: var(--ds-danger-border); }
/* Placeholders are text and owe 4.5:1 like any other text. */
.ds-textarea::placeholder { color: var(--ds-fg-muted); }
.ds-field__count[data-over='true'] { color: var(--ds-danger-text); }Component API
Textarea
| Prop | Type | Default | Description |
|---|---|---|---|
| rows | number | 4 | Initial height. This is a promise about the answer you expect — pick it deliberately. |
| autoResize | boolean | false | Grows with content up to maxRows. Disables the manual resize handle. |
| maxRows | number | 12 | The autosize ceiling. Past it the field scrolls. |
| size | 'sm' | 'md' | 'lg' | 'md' | Sets padding, type size and radius. |
| status | 'default' | 'error' | 'success' | 'warning' | 'default' | Drives the border and pairs with the Field message. |
| disabled | boolean | false | Removes the field from the tab order. Prefer readOnly when the value still matters. |
| readOnly | boolean | false | Focusable, selectable, copyable — but not editable. |
Field
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Always above the field. Never a placeholder. |
| description | string | — | Static guidance. Stays visible when an error appears. |
| message | string | — | Validation message. Stacks under the description rather than replacing it. |
| counter | { value: number; max: number } | — | Visible from the first character, and turns danger-toned when over. |
Professional tips
- Autosave drafts on a debounce. A long answer lost to a refresh is the most avoidable frustration this component has.
- Preserve the draft when validation fails elsewhere in the form. Users notice immediately when a rejected submit clears the paragraph they wrote.
- Set enterKeyHint on mobile so the on-screen keyboard offers a newline key rather than "Go".
- If the field accepts Markdown, say so under it and offer a preview toggle. Users type asterisks either way; the question is whether the product acknowledges it.
- Trim trailing whitespace on submit, not while typing. Trimming as the user types eats the space they just pressed before the next word.
Performance
- Autosize needs two writes and one read per keystroke: reset the height, read scrollHeight, set the height. Reading before the reset returns the previous height forever.
- Prefer CSS field-sizing: content where it is supported and keep the JS as a fallback — it removes the layout thrash entirely.
- Debounce autosave and validation by roughly 300ms. Validating on every keystroke in a long field is wasted work and produces errors while the user is mid-word.
- Do not re-render an entire form on every keystroke in one textarea. Keep the value local and lift it on blur when the form is large.
Common mistakes
- Submitting on bare Enter, silently destroying drafts.
- A counter that only appears near the limit, so the constraint is discovered after it is broken.
- A hard maxlength that swallows keystrokes mid-word with no explanation.
- Autosize with no ceiling, pushing the submit button off the screen.
- resize: both, letting the user break the form layout horizontally.
- Placeholder as label, which disappears exactly when the user needs the question most.
- A 12-row field for a one-sentence answer, which makes an honest short answer feel wrong.
Real-world recommendations
- Comment boxes should start at two rows and grow. A tall empty box reads as a demand for an essay, and it measurably reduces the number of people who reply at all.
- Character limits should be generous or absent. A 280-character limit on an internal deployment note is a rule inherited from a product that had a reason for it.
- Users paste far more than they type into textareas. Handle a pasted wall of text gracefully: grow to the ceiling, scroll, keep the counter accurate.
- If you find yourself adding formatting buttons above a textarea, you have outgrown the component. That is a rich-text editor, and pretending otherwise produces the worst of both.