Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
63/280

Visible to everyone with access to this project.

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.

⌘↵ to send

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.

Two rows“A sentence is fine”
Ten rows“We expect an essay”

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.

Grows to 12 rows, then scrolls.

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.

Under
64/280
Over
296/280

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.

Default
Filled
Focus
Error
Success
Disabled
Read only
Small
Large
Scrolling

Anatomy

Every part, every measurement, and the reason it is that number.

64/280

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. MessageStacks under the description

    It never replaces the description. Losing the instructions at the moment of failure is exactly backwards.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
paddingField padding

Radius

TokenValueUsed for
--radius-mdField corners

Typography

TokenValueUsed for
--text-bodyTyped text and its leading

Motion

TokenValueUsed for
--duration-fastBorder and halo transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusTypeMax widthWhen to use
Small2 rows ≈ 56px8px 10px8px13px—Inline comment boxes and dense forms.
Medium4 rows ≈ 96px10px 12px8px15px—The default. Descriptions and notes.
Large6 rows ≈ 152px12px 14px12px16px—The main content field on a page dedicated to writing.
Autosize ceiling12 rows————Grows with content to here, then scrolls. Non-negotiable — without it the submit button leaves the screen.
Measure————36remAbout 70 characters per line. Wider and the eye loses the start of the next line.

Do

⌘↵ to send
Let Enter insert a newlineIt is what the key means in a multi-line field. If a submit shortcut is needed, use ⌘/Ctrl+Enter and print it next to the button.
0/280
Show the counter from the first characterThe limit is a constraint on what to write, not a punishment discovered afterwards. At 290 of 280 the user is rewriting, not trimming.
296/280
Allow going over, then explainHard-blocking at the limit swallows keystrokes mid-word. Let the text through, mark it as an error, and say how much to cut.
Cap the measure at about 70 charactersA textarea stretched across a 1440px window produces lines the eye cannot track back from. Width is a readability setting, not a space-filling one.

Don't

onKeyDown: if (e.key === 'Enter') submit()
Do not submit on bare EnterThe user was writing a paragraph and the form went away. They have no idea what they pressed, and the draft is gone.
Do not allow horizontal resizeA field dragged wider than its container breaks the form layout and can push the submit button off-screen. Vertical only, always.
Do not use the placeholder as the labelIt disappears at the first keystroke, which for a long answer means the question is gone for the entire time it takes to answer it.
pasted log line… pasted log line… pasted log line… pasted log line… pasted log line… pasted log line… pasted log line… pasted log line… pasted log line…
Do not grow without a ceilingA pasted log file turns the field into the page and the submit button ends up below the fold with no indication it is there.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.5Identify Input PurposeAA2.1.1KeyboardA3.3.1Error IdentificationA3.3.3Error SuggestionAA4.1.3Status MessagesAA

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

EnterInserts a newline. Always.
⌘ / Ctrl + EnterSubmits, when the surrounding surface has a submit action. Must be printed next to the button.
TabLeaves the field. It must never insert a tab character — that traps keyboard users inside the textarea.
EscIn a comment box, optionally discards the draft. Confirm first if anything has been typed.
⌘ / Ctrl + ASelects 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".
AttributeApplied toNotes
<label for>The fieldA real label element. aria-label is the fallback for a field with no visible label, such as a bare comment box.
aria-describedbyThe fieldPoints at the description and, when present, the counter — so both are read after the label.
aria-invalidThe fieldSet when the value is invalid, including over the character limit.
aria-errormessageThe fieldPoints at the message element, which must be rendered before it is referenced.
role="status"The counterPolite, and throttled to roughly every 20 characters. Announcing every keystroke is unusable.
maxlengthThe fieldDeliberately omitted when you want to allow going over. A hard maxlength swallows keystrokes with no explanation.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
rowsnumber4Initial height. This is a promise about the answer you expect — pick it deliberately.
autoResizebooleanfalseGrows with content up to maxRows. Disables the manual resize handle.
maxRowsnumber12The 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.
disabledbooleanfalseRemoves the field from the tab order. Prefer readOnly when the value still matters.
readOnlybooleanfalseFocusable, selectable, copyable — but not editable.

Field

PropTypeDefaultDescription
labelstring—Always above the field. Never a placeholder.
descriptionstring—Static guidance. Stays visible when an error appears.
messagestring—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.

Notes

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.