Components · Expanding textarea
Components

Expanding textarea

A textarea that grows with what is typed into it, bounded at both ends. It exists because the review field is the one place in this product where people write more than a line, and a three-row box with a scrollbar tells them to stop.

The component

Organisers read every review. Names are never shown. 600 left
Try
Engine

The badge reports which mechanism your browser is using. Both produce the same geometry — the JS path exists only so the component behaves identically where field-sizing has not landed.

CSS first, JS as fallback

The whole behaviour is one declaration:

.xta__ta { field-sizing: content; min-height: 84px; max-height: 220px; overflow-y: auto }

No scrollHeight reads, no hidden mirror element, no layout thrash on every keystroke, and it works before JavaScript loads. Where field-sizing is unsupported, one fallback reproduces it:

if (!CSS.supports('field-sizing', 'content')) {
  const fit = () => {
    ta.style.height = 'auto';              // release, so scrollHeight is truthful
    ta.style.height = ta.scrollHeight + 'px'; // max-height still caps it
  };
  ta.addEventListener('input', fit);
  fit();                                    // and on load, for restored drafts
}

Never ship only the JS path. It runs after hydration, so a restored draft renders at three rows and jumps to full height a moment later — the most visible layout shift on the page, on the element the user is about to type into.

Bounded at both ends

Do

A floor of three rows — the field must look like it expects a paragraph.

Don’t

Start at one row. It reads as a search box and gets one-line answers.

The ceiling matters more than the floor. An unbounded textarea pushes the Submit button below the fold at around 400 words, and the user is then typing with no visible way to finish. At max-height the field stops growing and scrolls internally — the page stays still and the button stays put.

BoundValueWhy
Floor84px — 3 rowsSignals the expected length before a word is typed
Ceiling220px — about 8 rowsKeeps the submit button on screen at every viewport we support
Past the ceilingoverflow-y: autoScrolls inside the field, never the page
On shrinkReturns to the floor, never belowDeleting text must not collapse the layout under the cursor
Resize handleresize: noneA handle that fights an auto-sizing box produces a height neither party wants

The counter rule

A character counter is a warning device, not a scoreboard. It appears only when the limit is close enough to matter — here, the last 20% — and never sits at “0 / 600” while someone is deciding what to say.

Organisers read every review.62 left
Do

Appear at 80% of the limit, counting down what remains.

Organisers read every review.4 / 600
Don’t

Show a running total from the first keystroke — it reads as a quota.

  • Count down, not up. “62 left” is actionable; “538 / 600” is arithmetic homework.
  • aria-live="polite", and only on the counter. Not assertive — interrupting someone mid-sentence to tell them they have 61 characters left is worse than saying nothing.
  • Past the limit, the count goes negative and turns --color-destructive, and submit is blocked with a message. Truncating what someone typed is never acceptable.
  • If there is no hard limit, there is no counter. Do not invent one to look thorough.

What Enter does

Enter inserts a newline. That is what a textarea is for, and overriding it to submit breaks the one key people rely on for paragraphs.

Submit-on-Enter belongs to a message composer, which is a different component with a different contract: one visible hint (“Enter to send, Shift+Enter for a new line”), and it must never be used for a field someone spends two minutes writing. A review lost to a stray Enter is a review that does not get retyped.

Specification

PropertyValueWhy
Border1px --color-border-strongThe same 3.06:1 boundary as every other control
Padding10px 12pxMatches Input, so a stacked form aligns
Line height1.6Multi-line prose, not UI text
Font size--text-ui — 15pxThe same size as every other control. See the note below
Growth stepOne line heightSub-line growth makes the box appear to shiver as you type
FocusThe one system focus ringNo shadow, no glow
ErrorBorder + message, never a red fillFill destroys the contrast of the text being corrected

Open question: 15px controls and iOS zoom. --text-ui is 15px, and mobile Safari zooms the page whenever a focused field is under 16px. That affects every control in this system, not just this one, so the fix belongs at the token layer — either --text-ui becomes 16px on coarse pointers, or controls move to --text-base. Overriding it on this page alone would make the textarea the only field that does not zoom, which is worse than the zoom.

Markup

<div class="xta">
  <label class="xta__lbl" for="review">What could have gone better?</label>
  <textarea class="xta__ta" id="review" rows="3" maxlength="600"
            placeholder="The bag drop queue took 40 minutes…"></textarea>
  <div class="xta__foot">
    <span>Organisers read every review. Names are never shown.</span>
    <span class="xta__count" aria-live="polite"></span>
  </div>
</div>

rows="3" stays on the element even though CSS sets the floor: it is the pre-CSS height, and it is what the field renders at if the stylesheet is slow.

React

From @sportingscouter/ui-react. The component adds no visual values of its own — every class comes from the same tv() recipe the design system defines, so the two cannot drift.

import { Textarea } from '@sportingscouter/ui-react';

<Textarea
  label="What could have gone better?"
  autoGrow
  maxRows={12}
  maxLength={600}
  hint="Organisers read every word. Two sentences is plenty."
/>

{/* maxRows is not optional in practice: uncapped growth pushes the
    submit button off-screen. Past the cap the field scrolls. */}
Sporting Scouter Design System
Every token on this site is generated from theme.css.