Use field-sizing: content to let the browser size a textarea to its content where it is supported, and elsewhere compute the height on input and from a ResizeObserver on the textarea's width — because a narrower textarea wraps more lines and needs to grow even when nobody typed.
Problem / Scenario Context
A comment box grows as the user types, using the classic trick: on every input, set height: auto, read scrollHeight, write it back. It works while typing. But the comment box sits in a responsive layout: when the sidebar opens, the textarea narrows, its text re-wraps onto more lines, and a scrollbar appears inside it because nothing recalculated the height. On some browsers, the height: auto reset also causes the page to jump when the textarea is near the bottom of a scrolled page.
Auto-height depends on two inputs — content and width — and the classic trick only listens to one. The Element Resize Detection Patterns topic covers width-driven layouts; this page applies it to textareas.
Mechanics Explanation
A textarea's scrollHeight is the height its content needs at its current width. Change the width and the content wraps differently, so scrollHeight changes too — but no input event fires.
The classic algorithm also has two layout costs: writing height: auto invalidates layout, reading scrollHeight forces it, and writing the new height invalidates it again. That is one forced layout per keystroke — fine for one textarea, noticeable for a page of editable table cells. The temporary collapse to auto height can shrink the document, and if the page is scrolled near its bottom, the browser clamps the scroll position, producing a jump.
The modern alternative is CSS: field-sizing: content makes form controls size to their content, including textareas, with min-height/max-height (or min-block-size/max-block-size) as bounds. It is handled entirely in layout — no script, no forced layouts, correct at every width. At the time of writing it is supported in Chromium-based browsers, with other engines working on it, so a fallback is still needed.
A ResizeObserver fills the width gap in the fallback: observe the textarea, and when its inline size changes, recompute the height.
Comparison Table: Auto-Height Techniques
| Technique | Reacts to typing | Reacts to width change | Forced layouts | Scroll jump risk |
|---|---|---|---|---|
height: auto + scrollHeight on input |
yes | no | 1 per keystroke | yes |
Same + ResizeObserver on width |
yes | yes | 1 per change | yes |
| Hidden mirror element sized by CSS grid | yes | yes | 0 | no |
field-sizing: content |
yes | yes | 0 | no |
contenteditable div |
yes | yes | 0 | no, but not a form control |
Minimal Reproducible Example
const ta = document.querySelector<HTMLTextAreaElement>('textarea.comment')!;
ta.addEventListener('input', () => {
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight}px`;
});
Type four lines, then open the sidebar so the textarea narrows: the text wraps onto six lines and an internal scrollbar appears.
Production-Safe Solution
textarea.autosize {
field-sizing: content; /* modern engines: done */
min-block-size: 3lh; /* at least three lines */
max-block-size: 20lh; /* then scroll */
resize: none;
}
export function autosize(ta: HTMLTextAreaElement): () => void {
if (CSS.supports('field-sizing', 'content')) return () => {}; // CSS handles everything
let lastWidth = -1;
let raf = 0;
const fit = (): void => {
// Preserve page scroll: the temporary collapse can clamp it.
const scrollY = window.scrollY;
ta.style.height = 'auto';
const h = Math.min(ta.scrollHeight, parseFloat(getComputedStyle(ta).maxHeight) || Infinity);
ta.style.height = `${h}px`;
ta.style.overflowY = ta.scrollHeight > h ? 'auto' : 'hidden';
if (window.scrollY !== scrollY) window.scrollTo({ top: scrollY });
};
const onInput = (): void => { cancelAnimationFrame(raf); raf = requestAnimationFrame(fit); };
ta.addEventListener('input', onInput);
// Width changes re-wrap text: recompute. Height changes are our own writes: ignore.
const ro = new ResizeObserver(([e]) => {
const w = e.contentBoxSize[0].inlineSize;
if (Math.abs(w - lastWidth) < 0.5) return;
lastWidth = w;
fit();
});
ro.observe(ta);
return () => { ta.removeEventListener('input', onInput); ro.disconnect(); cancelAnimationFrame(raf); };
}
The ResizeObserver callback compares only the inline size, so its own height writes — which change the block size and deliver an entry — are ignored, and the loop that would otherwise occur never starts (see fixing ResizeObserver loop limit exceeded). Coalescing input events to one fit() per frame keeps fast typing and paste cheap. Saving and restoring scrollY removes the jump caused by the temporary collapse.
The Zero-Layout Alternative: A Mirror Element
When field-sizing is unavailable and many textareas are on the page — an editable grid, say — the forced layout per change adds up. A layout-only fallback uses a hidden mirror element in the same grid cell as the textarea; the mirror's content is kept in sync, and CSS grid makes the cell as tall as the taller of the two:
.grow-wrap { display: grid; }
.grow-wrap::after {
content: attr(data-value) " "; /* trailing space keeps the last empty line */
white-space: pre-wrap;
visibility: hidden;
}
.grow-wrap > textarea, .grow-wrap::after {
grid-area: 1 / 1 / 2 / 2;
font: inherit; padding: 0.5rem; border: 1px solid transparent; /* identical box model */
}
.grow-wrap > textarea { resize: none; overflow: hidden; }
ta.addEventListener('input', () => { (ta.parentElement as HTMLElement).dataset.value = ta.value; });
Width changes need no handling at all: the mirror wraps exactly like the textarea because they share a grid cell and box model. The only script is copying the value on input. The cost is keeping the two box models identical, which is a styling discipline rather than runtime work.
Verification Steps
- Type, paste and delete multi-line text and confirm the textarea grows and shrinks without an internal scrollbar below the max height.
- Narrow the layout (open a sidebar, resize the window) with text present and confirm the textarea grows to fit the re-wrapped text.
- Scroll to the bottom of a long page and type in a textarea there; the page must not jump.
- Check the console for ResizeObserver loop errors while resizing.
- Test in a browser with
field-sizingsupport and confirm the script path does nothing.
Common Mistakes to Avoid
- Listening only to
input. Width changes re-wrap text too. - Reacting to every ResizeObserver entry. Your own height writes deliver entries; compare inline size.
- Forgetting the max height. Very long text should scroll, not grow forever.
- Different box models between mirror and textarea. A padding or font mismatch makes the mirror wrap differently.
FAQ
What does field-sizing: content do?
It makes form controls size themselves to their content instead of using a fixed default size. For textareas, that means the height grows with the text, bounded by min and max sizes, entirely in CSS.
Why does the classic approach cause the page to jump?
Setting height to auto briefly shrinks the textarea, which can make the document shorter. If the page was scrolled close to its end, the browser clamps the scroll position, and restoring the height does not restore the scroll.
Is it safe to set the height inside the ResizeObserver callback?
Yes, as long as you only act on inline-size changes. The height write changes the block size, which delivers another entry that the callback ignores because the inline size is unchanged.
What does the lh unit mean in the CSS?
lh is the computed line height of the element, so 3lh is exactly three lines of text regardless of font size.
Does this work in React or Vue?
Yes. Call autosize from the component's mount hook with the textarea ref and call the returned cleanup on unmount. In React, avoid storing the height in state; writing it directly avoids a re-render per keystroke.
Should I debounce the input handler?
Coalescing to one update per animation frame is enough. A time-based debounce would let the textarea visibly lag behind typing.
Related
- Truncating Text Responsively with ResizeObserver — the opposite response to overflow
- Detecting Container Queries with ResizeObserver — CSS-first thinking
- Handling the Mobile Virtual Keyboard with visualViewport — textareas and the keyboard
↑ Back to Element Resize Detection Patterns