Building a Textarea Component That Grows with Its Content

A fixed-height textarea is one of those small friction points that quietly frustrates users. They type more than the box holds, lose sight of what they wrote, and start scrolling inside a tiny rectangle. Auto-resize solves this in about ten lines — but there are a few details that make the difference between a resize that feels native and one that jumps around.
The Textarea shares most of its DNA with the Input we built in article 7. Same validation states, same error and hint pattern, same forwardRef requirement, same token-backed classes. What makes it distinct is one behaviour: it should grow vertically as the user types, and shrink back when they delete.
A fixed-height textarea forces users to scroll inside a small box. That's a frustrating experience, and it's completely avoidable. We're building auto-resize in from the start — not as an optional prop, but as the default behaviour.
The auto-resize approach
There are two common approaches to auto-resize:
Approach 1 — scrollHeight: On every input event, set the element's height to auto to collapse it, read its scrollHeight, then set the height to that value. Simple, reliable, works in all browsers.
Approach 2 — CSS field-sizing: content: A newer CSS property that makes the textarea grow automatically without any JavaScript. Browser support is still catching up — not ready for a library targeting broad compatibility.
We're using Approach 1. It's four lines of logic and it works everywhere.
The four files
Following the structure established in previous articles:
Textarea.variants.ts
The Textarea shares the same state and size variant structure as Input — consistent API across all form fields is intentional:
Two things differ from inputVariants:
py-2 is added to the base classes — Input uses height to control its size, Textarea grows vertically so it needs explicit vertical padding instead.
resize-none overflow-hidden — we disable the native browser resize handle since we're managing height ourselves. overflow-hidden prevents a scrollbar flash during the height recalculation.
The size variant no longer sets h-* — height is dynamic. The size variant only controls text size here, which keeps the font scale consistent with Input across form layouts.
Textarea.types.ts
minRows and maxRows give consumers control over the growth bounds — the minimum height the textarea starts at, and the maximum it can grow to before it starts scrolling internally. Both are optional with sensible defaults in the component.
Textarea.tsx
What each decision is doing
innerRef alongside the forwarded ref — the auto-resize logic needs direct access to the DOM element via innerRef. But we also need to forward the external ref to consumers. The mergedRef callback handles both — it sets innerRef.current and passes the node to the forwarded ref simultaneously. This is the correct pattern any time you need an internal ref on a component that also forwards a ref.
resize on mount via useEffect — if the Textarea is initialised with a defaultValue or value, the content is already there before the user types anything. Running resize() on mount ensures the initial height reflects the existing content rather than starting collapsed.
getComputedStyle for line height — hardcoding a line height value would break the moment a consumer changes the font or size. Reading it from getComputedStyle means the calculation always reflects the actual rendered dimensions, regardless of what Tailwind size variant or custom font is in use.
overflowY toggles to auto at maxRows — once the content exceeds maxRows, the textarea stops growing and shows a scrollbar instead. Below maxRows, overflow stays hidden to prevent the scrollbar flash during height recalculation.
onChange is wrapped not replaced — we call resize() inside handleChange then forward the original onChange event to the consumer. This means consumers who pass their own onChange handler — react-hook-form, for example — get their handler called as normal. We never swallow the event.
The component index
src/components/Textarea/index.ts:
Update the root entry point
Add to src/index.ts:
Usage
The decision rule
Use minRows to set the visual starting point — a comment field that expects short responses starts at 2 rows, a notes field that expects long content starts at 5. Use maxRows to protect page layout — an unbounded textarea in a form can push every element below it off screen as the user types. Set a ceiling and let it scroll internally beyond that point.
Up next: Building a Checkbox component — custom styles without losing native behaviour — the first form field that isn't text-based, and the one that forces the question of how much native browser behaviour to keep.
Related: Building an Input component with validation states and accessibility built in — Textarea and Input share the same error, hint, and label connection pattern. If you haven't built Input yet, start there first.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Dan Counsell on Unsplash

