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
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
A floor of three rows — the field must look like it expects a paragraph.
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.
| Bound | Value | Why |
|---|---|---|
| Floor | 84px — 3 rows | Signals the expected length before a word is typed |
| Ceiling | 220px — about 8 rows | Keeps the submit button on screen at every viewport we support |
| Past the ceiling | overflow-y: auto | Scrolls inside the field, never the page |
| On shrink | Returns to the floor, never below | Deleting text must not collapse the layout under the cursor |
| Resize handle | resize: none | A 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.
Appear at 80% of the limit, counting down what remains.
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. Notassertive— 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
| Property | Value | Why |
|---|---|---|
| Border | 1px --color-border-strong | The same 3.06:1 boundary as every other control |
| Padding | 10px 12px | Matches Input, so a stacked form aligns |
| Line height | 1.6 | Multi-line prose, not UI text |
| Font size | --text-ui — 15px | The same size as every other control. See the note below |
| Growth step | One line height | Sub-line growth makes the box appear to shiver as you type |
| Focus | The one system focus ring | No shadow, no glow |
| Error | Border + message, never a red fill | Fill 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. */}theme.css.