useTextareaAutosize grows and shrinks a <textarea> so its height always matches the text inside it — the "expanding comment box" you have used on GitHub, Slack, and every modern message composer. A plain textarea has a fixed height and scrolls its overflow; there is no CSS that makes a classic textarea hug its content, so the height has to be measured and set in JavaScript. You will build the hook that does it, re-measuring on every change to the value.
function useTextareaAutosize(
value: string, // the same value you bind to the <textarea>
options?: {
minRows?: number; // clamp: never shorter than this many rows
maxRows?: number; // clamp: never taller — past this it scrolls
},
): React.RefObject<HTMLTextAreaElement>; // attach to the <textarea>
Attach the returned ref to a <textarea> whose value prop is the same value you pass the hook. It measures on mount and every time value changes.
function Composer() {
const [text, setText] = useState('');
const ref = useTextareaAutosize(text, { minRows: 2, maxRows: 8 });
return (
<textarea ref={ref} value={text} onChange={(e) => setText(e.target.value)} />
);
}
// Given a textarea whose one row is 20px tall (line-height 20, no padding):
// value = 'hi' -> height 20px (1 row)
// value = 'line1\nline2\nline3'-> height 60px (grows to 3 rows)
// value = 'hi' (deleted back) -> height 20px (shrinks back down)
// value = '', minRows: 3 -> height 60px (floored at 3 rows)
// value = 12 rows, maxRows: 8 -> height 160px + overflow-y: auto (scrolls)
scrollHeight and write it to style.height. There is a catch in scrollHeight that decides whether the box can shrink; finding it is the point of the question.useLayoutEffect, not useEffect, or the user sees a one-frame flash of the wrong height on every keystroke.box-sizing: border-box on the textarea and say so; the number you write to height depends on the box model.line-height. Past maxRows, set overflow-y: auto so extra lines scroll instead of growing the box forever.value; the caller does. The hook only reads the element and sets its height.You will measure a textarea's content height in JavaScript and write it back to the element's own height on every change — with one reset that is the entire reason the box can shrink and not just grow.
A <textarea> has a fixed height. Type past the bottom and it does not get taller; it scrolls. There is no pure-CSS way to make a plain textarea hug its content, so a comment box that grows as you write has to be measured and resized by hand. The tool for the measurement is scrollHeight — the full height the content wants, including the part hidden by the scroll. Write that number to style.height and the box fits its text. Do it on every keystroke and the box tracks the text as it is written.
Here is the trap that is this question. scrollHeight is not "the height of the content." It is the larger of the content height and the element's current height. So the moment you have grown the box to five rows, scrollHeight can never report anything shorter than five rows — the box's own height is now the floor. Delete three lines and measure again and you still read five rows, so you write five rows back, and the box is stuck tall. To get an honest measurement you first collapse the height, then read.
The obvious version keeps a ref, and on every change reads scrollHeight and writes it to height:
const { useRef, useLayoutEffect } = require('react');
function useTextareaAutosize(value) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = el.scrollHeight + 'px';
}, [value]);
return ref;
}
Type into it and it grows perfectly, which is why it looks finished. Then delete a few lines and nothing happens. On that render the element is still tall from before, so scrollHeight reports the tall height, and height = scrollHeight writes the same tall height straight back. The box can climb but never descends.
const { useRef, useLayoutEffect } = require('react');
function useTextareaAutosize(value, options) {
const opts = options || {};
const minRows = opts.minRows;
const maxRows = opts.maxRows;
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
// Reset first: collapse the height so it stops being the floor for
// scrollHeight. Now scrollHeight reports the TRUE content height.
el.style.height = 'auto';
let next = el.scrollHeight;
// Optional row clamp. One row is worth `lineHeight` pixels; padding is part
// of scrollHeight, so add it to keep the same basis.
if (minRows != null || maxRows != null) {
const cs = getComputedStyle(el);
const rowHeight = parseFloat(cs.lineHeight) || 0;
const paddingY =
(parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
if (maxRows != null && rowHeight) {
const maxHeight = maxRows * rowHeight + paddingY;
if (next > maxHeight) {
next = maxHeight;
el.style.overflowY = 'auto'; // past the cap: let it scroll, not grow
} else {
el.style.overflowY = 'hidden';
}
}
if (minRows != null && rowHeight) {
const minHeight = minRows * rowHeight + paddingY;
if (next < minHeight) next = minHeight;
}
}
el.style.height = next + 'px';
}, [value, minRows, maxRows]);
return ref;
}
module.exports = { useTextareaAutosize };
The one changed line — el.style.height = 'auto' before reading — is the whole fix. It drops the height floor so scrollHeight reports what the content actually needs, up or down. Everything after it is bookkeeping: read the honest height, optionally clamp it to a row range, and write it back. The clamp turns rows into pixels through the computed line-height, and once the content is taller than maxRows it stops growing and scrolls instead.
useLayoutEffect, and which box you are measuringTwo details make the difference between smooth and janky.
Timing. The measure-and-set runs in useLayoutEffect, not useEffect. useLayoutEffect fires after React writes the DOM but before the browser paints, so the reset-to-auto and the final height both land in the same frame — the user never sees the collapse, and never sees a wrong-sized box flash before it corrects. With useEffect the browser can paint the stale height first, then jump.
Box model. This assumes box-sizing: border-box on the textarea, the usual reset. scrollHeight counts content plus padding (never border), and under border-box the height you set also covers padding — so scrollHeight is the number to write. Under content-box, height is content only, so you would subtract the vertical padding (scrollHeight - paddingTop - paddingBottom); a visible border needs adding back on top. react-textarea-autosize reads the exact padding and border from getComputedStyle and adjusts both ways.
Say the box holds five lines and the user selects and deletes three, leaving two. value changes, so the layout effect runs:
el.style.height = 'auto'. The box collapses to whatever its content needs — two lines — instead of staying at its old five-line height.el.scrollHeight now reads the two-line height, say 40px. Before the reset it would still have read the five-line 100px, because the element was that tall.{ minRows: 2, maxRows: 8 }, 40px is inside the range, so it passes through untouched; overflow-y is set to hidden because we are under the cap.el.style.height = '40px'. The box shrinks to fit. Next frame the user types a sixth line; the same steps run and it grows to 120px, then to 160px (eight rows) where maxRows pins it and overflow-y flips to auto so further lines scroll.height = scrollHeight with no height = 'auto' first grows but never shrinks, because the box's current height is the floor scrollHeight reports. Collapse the height before you read it.useEffect instead of useLayoutEffect. The box paints at the old height for one frame, then snaps to the new one — a visible flicker on every keystroke. useLayoutEffect resizes before paint.content-box, writing raw scrollHeight leaves the box a little too tall by its padding. State your box-sizing and adjust, or reset to border-box.maxRows, a pasted essay makes the textarea taller than the viewport and there is no way to scroll it. Clamp the height and set overflow-y: auto past the cap.line-height: normal. parseFloat('normal') is NaN, so a textarea with no explicit line-height breaks the row math. Set an explicit line-height, or measure a real single-row height the way a hidden-clone implementation does.<textarea> forced to height: 0, copies the real element's font, padding, border, and width onto it, sets its value, and reads its scrollHeight. That avoids writing to the live element twice per keystroke and can measure a true one-row height for the clamp — at the cost of mirroring roughly two dozen computed styles.field-sizing. textarea { field-sizing: content } makes the browser grow and shrink the control with zero JavaScript. As of 2026 it is Baseline across Chromium, Firefox, and Safari — but only in recent versions (Safari gained it in 26.2, Firefox in 152), so a JS fallback like this hook is still needed for older browsers. Check current support before dropping the script.auto, read scrollHeight, write the height). On a small comment box that is free; on a megabyte of pasted text it can stutter. Throttling the resize to animation frames keeps typing responsive.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useTextareaAutosize grows and shrinks a <textarea> so its height always matches the text inside it — the "expanding comment box" you have used on GitHub, Slack, and every modern message composer. A plain textarea has a fixed height and scrolls its overflow; there is no CSS that makes a classic textarea hug its content, so the height has to be measured and set in JavaScript. You will build the hook that does it, re-measuring on every change to the value.
function useTextareaAutosize(
value: string, // the same value you bind to the <textarea>
options?: {
minRows?: number; // clamp: never shorter than this many rows
maxRows?: number; // clamp: never taller — past this it scrolls
},
): React.RefObject<HTMLTextAreaElement>; // attach to the <textarea>
Attach the returned ref to a <textarea> whose value prop is the same value you pass the hook. It measures on mount and every time value changes.
function Composer() {
const [text, setText] = useState('');
const ref = useTextareaAutosize(text, { minRows: 2, maxRows: 8 });
return (
<textarea ref={ref} value={text} onChange={(e) => setText(e.target.value)} />
);
}
// Given a textarea whose one row is 20px tall (line-height 20, no padding):
// value = 'hi' -> height 20px (1 row)
// value = 'line1\nline2\nline3'-> height 60px (grows to 3 rows)
// value = 'hi' (deleted back) -> height 20px (shrinks back down)
// value = '', minRows: 3 -> height 60px (floored at 3 rows)
// value = 12 rows, maxRows: 8 -> height 160px + overflow-y: auto (scrolls)
scrollHeight and write it to style.height. There is a catch in scrollHeight that decides whether the box can shrink; finding it is the point of the question.useLayoutEffect, not useEffect, or the user sees a one-frame flash of the wrong height on every keystroke.box-sizing: border-box on the textarea and say so; the number you write to height depends on the box model.line-height. Past maxRows, set overflow-y: auto so extra lines scroll instead of growing the box forever.value; the caller does. The hook only reads the element and sets its height.You will measure a textarea's content height in JavaScript and write it back to the element's own height on every change — with one reset that is the entire reason the box can shrink and not just grow.
A <textarea> has a fixed height. Type past the bottom and it does not get taller; it scrolls. There is no pure-CSS way to make a plain textarea hug its content, so a comment box that grows as you write has to be measured and resized by hand. The tool for the measurement is scrollHeight — the full height the content wants, including the part hidden by the scroll. Write that number to style.height and the box fits its text. Do it on every keystroke and the box tracks the text as it is written.
Here is the trap that is this question. scrollHeight is not "the height of the content." It is the larger of the content height and the element's current height. So the moment you have grown the box to five rows, scrollHeight can never report anything shorter than five rows — the box's own height is now the floor. Delete three lines and measure again and you still read five rows, so you write five rows back, and the box is stuck tall. To get an honest measurement you first collapse the height, then read.
The obvious version keeps a ref, and on every change reads scrollHeight and writes it to height:
const { useRef, useLayoutEffect } = require('react');
function useTextareaAutosize(value) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = el.scrollHeight + 'px';
}, [value]);
return ref;
}
Type into it and it grows perfectly, which is why it looks finished. Then delete a few lines and nothing happens. On that render the element is still tall from before, so scrollHeight reports the tall height, and height = scrollHeight writes the same tall height straight back. The box can climb but never descends.
const { useRef, useLayoutEffect } = require('react');
function useTextareaAutosize(value, options) {
const opts = options || {};
const minRows = opts.minRows;
const maxRows = opts.maxRows;
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
// Reset first: collapse the height so it stops being the floor for
// scrollHeight. Now scrollHeight reports the TRUE content height.
el.style.height = 'auto';
let next = el.scrollHeight;
// Optional row clamp. One row is worth `lineHeight` pixels; padding is part
// of scrollHeight, so add it to keep the same basis.
if (minRows != null || maxRows != null) {
const cs = getComputedStyle(el);
const rowHeight = parseFloat(cs.lineHeight) || 0;
const paddingY =
(parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
if (maxRows != null && rowHeight) {
const maxHeight = maxRows * rowHeight + paddingY;
if (next > maxHeight) {
next = maxHeight;
el.style.overflowY = 'auto'; // past the cap: let it scroll, not grow
} else {
el.style.overflowY = 'hidden';
}
}
if (minRows != null && rowHeight) {
const minHeight = minRows * rowHeight + paddingY;
if (next < minHeight) next = minHeight;
}
}
el.style.height = next + 'px';
}, [value, minRows, maxRows]);
return ref;
}
module.exports = { useTextareaAutosize };
The one changed line — el.style.height = 'auto' before reading — is the whole fix. It drops the height floor so scrollHeight reports what the content actually needs, up or down. Everything after it is bookkeeping: read the honest height, optionally clamp it to a row range, and write it back. The clamp turns rows into pixels through the computed line-height, and once the content is taller than maxRows it stops growing and scrolls instead.
useLayoutEffect, and which box you are measuringTwo details make the difference between smooth and janky.
Timing. The measure-and-set runs in useLayoutEffect, not useEffect. useLayoutEffect fires after React writes the DOM but before the browser paints, so the reset-to-auto and the final height both land in the same frame — the user never sees the collapse, and never sees a wrong-sized box flash before it corrects. With useEffect the browser can paint the stale height first, then jump.
Box model. This assumes box-sizing: border-box on the textarea, the usual reset. scrollHeight counts content plus padding (never border), and under border-box the height you set also covers padding — so scrollHeight is the number to write. Under content-box, height is content only, so you would subtract the vertical padding (scrollHeight - paddingTop - paddingBottom); a visible border needs adding back on top. react-textarea-autosize reads the exact padding and border from getComputedStyle and adjusts both ways.
Say the box holds five lines and the user selects and deletes three, leaving two. value changes, so the layout effect runs:
el.style.height = 'auto'. The box collapses to whatever its content needs — two lines — instead of staying at its old five-line height.el.scrollHeight now reads the two-line height, say 40px. Before the reset it would still have read the five-line 100px, because the element was that tall.{ minRows: 2, maxRows: 8 }, 40px is inside the range, so it passes through untouched; overflow-y is set to hidden because we are under the cap.el.style.height = '40px'. The box shrinks to fit. Next frame the user types a sixth line; the same steps run and it grows to 120px, then to 160px (eight rows) where maxRows pins it and overflow-y flips to auto so further lines scroll.height = scrollHeight with no height = 'auto' first grows but never shrinks, because the box's current height is the floor scrollHeight reports. Collapse the height before you read it.useEffect instead of useLayoutEffect. The box paints at the old height for one frame, then snaps to the new one — a visible flicker on every keystroke. useLayoutEffect resizes before paint.content-box, writing raw scrollHeight leaves the box a little too tall by its padding. State your box-sizing and adjust, or reset to border-box.maxRows, a pasted essay makes the textarea taller than the viewport and there is no way to scroll it. Clamp the height and set overflow-y: auto past the cap.line-height: normal. parseFloat('normal') is NaN, so a textarea with no explicit line-height breaks the row math. Set an explicit line-height, or measure a real single-row height the way a hidden-clone implementation does.<textarea> forced to height: 0, copies the real element's font, padding, border, and width onto it, sets its value, and reads its scrollHeight. That avoids writing to the live element twice per keystroke and can measure a true one-row height for the clamp — at the cost of mirroring roughly two dozen computed styles.field-sizing. textarea { field-sizing: content } makes the browser grow and shrink the control with zero JavaScript. As of 2026 it is Baseline across Chromium, Firefox, and Safari — but only in recent versions (Safari gained it in 26.2, Firefox in 152), so a JS fallback like this hook is still needed for older browsers. Check current support before dropping the script.auto, read scrollHeight, write the height). On a small comment box that is free; on a megabyte of pasted text it can stutter. Throttling the resize to animation frames keeps typing responsive.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.