How to Build a Proper Auto-Growing Textarea

Just a guy who loves to write code and watch anime.
The Problem With a Fixed Textarea
A normal <textarea> has a fixed height. When content overflows that height, the browser adds a vertical scrollbar. This is fine for long-form editing like a notes app. It is bad for prompt inputs, chat boxes, or comment fields where you want the user to see all of what they typed at all times without scrolling inside the element.
The goal is an element that starts at one line of height and grows downward as content fills it.
Step 1. Start With the Right CSS
Before writing any JavaScript, you need to set up the textarea with CSS that allows it to grow.
textarea {
resize: none;
overflow: hidden;
min-height: 40px;
}
resize: none removes the drag handle that browsers add to the bottom-right corner of textareas. Without this, the user can manually drag the element to a fixed size, which fights against your auto-grow logic.
overflow: hidden is the most important rule here. When overflow is hidden, the browser does not add a scrollbar when content overflows. This has a side effect that makes the auto-grow technique possible: scrollHeight now correctly reports the full intrinsic height of the content, not just the visible portion. If overflow is not hidden, scrollHeight can return wrong or inconsistent values across browsers.
min-height sets the floor. The textarea will never shrink below this value even when empty.
Step 2. Understand scrollHeight
scrollHeight is a DOM property on any element. It returns the total height of the element's content in pixels, including content that is not visible because it overflows. Think of it as the height the element would need to be to show all its content without any scrollbar.
For a textarea, scrollHeight includes the text, the line height, and the padding on top and bottom.
This is the number you want to assign to the element's height so it exactly fits its content.
Step 3. The Core Resize Technique
The technique that actually works, consistently, across all browsers is this:
function resize(el) {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
Two lines. But the order matters and both lines are necessary.
Why set height to auto first.
If you just set el.style.height = el.scrollHeight + 'px' without resetting first, you get a bug when the user deletes text. The element already has an explicit pixel height set from the previous resize call. The browser uses that pixel height as the element's size when calculating scrollHeight. So scrollHeight reports the old height, not the new smaller one. The element never shrinks.
Setting height to auto first tells the browser to forget the explicit height and recalculate the element's natural height from its content. Now scrollHeight gives you the true intrinsic height. Then you set that as the explicit height.
Both assignments happen synchronously in the same function call. The browser does not repaint between them. So the user never sees the auto state. There is no flicker.
Step 4. When to Call resize
You need to call resize every time the content changes. In plain JavaScript this is the input event.
const textarea = document.querySelector("textarea");
textarea.addEventListener("input", () => {
resize(textarea);
});
The input event fires after the value has changed, which means scrollHeight is already up to date when your function runs.
You also need to call resize once when the page loads if the textarea has a pre-filled value. Otherwise it will start at its CSS min-height even if the content is taller.
// Call once on load for pre-filled values
resize(textarea);
Step 5. Putting It Together in Plain JavaScript
function resize(el) {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
const textarea = document.querySelector("textarea");
// Resize once on load in case value is pre-filled
resize(textarea);
// Resize on every input event
textarea.addEventListener("input", () => resize(textarea));
textarea {
resize: none;
overflow: hidden;
min-height: 40px;
max-height: 200px; /* optional ceiling */
}
If you want a ceiling on the height, add max-height in CSS. Once the content exceeds that height, the element stops growing and the overflow becomes scrollable. You will want to switch overflow to auto at that point so the scrollbar appears only when needed.
Step 6. A Clean React Hook
In React you want the logic in a reusable hook so you can drop it into any component.
import { useCallback, useEffect, useRef } from "react";
export function useAutoResize(value: string) {
const ref = useRef<HTMLTextAreaElement>(null);
const resize = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, []);
// Re-run every time the value changes
useEffect(() => {
resize();
}, [value, resize]);
return ref;
}
The hook takes value as a dependency for useEffect. Every time value changes, the effect runs and calls resize. This keeps the height perfectly in sync with the controlled value.
You use the hook like this:
function CommentBox() {
const [value, setValue] = useState("");
const textareaRef = useAutoResize(value);
return (
<textarea
ref={textareaRef}
value={value}
rows={1}
onChange={(e) => setValue(e.target.value)}
style={{ resize: "none", overflow: "hidden" }}
/>
);
}
rows={1} sets the starting height to one line. Without this, the HTML default is rows={2}, so the element starts taller than one line even when empty.
Step 7. Resetting Height After Submit
If you clear the value on submit, you also need to reset the height. Setting value to an empty string does trigger the useEffect because the value changed. The resize call will run and set the height back to the natural single-line height. This works automatically with the hook above as long as you clear value through state.
If you ever clear the textarea by directly mutating el.value instead of through React state, the effect will not run. Always go through state.
Step 8. Handling Window Resize
When the window gets narrower, text reflows. Lines that fit on one line now wrap onto two. The height needs to update to match. The input event does not fire when the window resizes, so you need to handle this separately.
useEffect(() => {
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [resize]);
Add this inside the hook. Without it, the textarea height becomes wrong when the user resizes their browser window.
Step 9. The Complete Hook With All Edge Cases
import { useCallback, useEffect, useRef } from "react";
export function useAutoResize(value: string) {
const ref = useRef<HTMLTextAreaElement>(null);
const resize = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, []);
// Resize when value changes
useEffect(() => {
resize();
}, [value, resize]);
// Resize when window resizes (text reflow)
useEffect(() => {
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [resize]);
return ref;
}
Summary of Why Each Piece Is There
| Piece | Why it is there |
|---|---|
overflow: hidden |
Makes scrollHeight accurate. Removes the scrollbar. |
resize: none |
Stops the user from manually overriding your height logic. |
el.style.height = 'auto' |
Clears the previous explicit height so scrollHeight reflects true content size. |
el.style.height = scrollHeight + 'px' |
Applies the correct height to the element. |
rows={1} |
Sets the single-line starting point. |
value in useEffect deps |
Triggers the resize on every content change. |
window resize listener |
Handles text reflow when viewport width changes. |
Every single piece has a specific reason. Remove any one of them and you get a bug.






