The CSS way: field-sizing
field-sizing: content tells the browser to size the field to its text. Tailwind v4 has it as field-sizing-content. Add a max height and the box scrolls once it gets there.
<textarea
rows={1}
placeholder="Ask anything"
className="field-sizing-content max-h-48 min-h-11 w-full resize-none overflow-y-auto rounded-xl border border-zinc-700 bg-zinc-900 px-4 py-2.5 leading-6 outline-none focus:border-violet-300"
/>That is the whole thing in Chrome and Edge. Support in Safari and Firefox has been arriving later, so check caniuse for your audience. Where it is missing, the box simply stays one row, which still works. If you need it to grow everywhere today, use the hook.
The hook: works in every browser
Set the height to auto so the box can shrink, read scrollHeight, and set the height to that, capped at a number of rows. useLayoutEffect runs before paint, so there is no flicker.
"use client";
import { useLayoutEffect, type RefObject } from "react";
/** Grows a textarea with its text, up to maxRows, then scrolls inside. */
export function useAutosize(ref: RefObject<HTMLTextAreaElement | null>, value: string, maxRows = 8) {
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const s = getComputedStyle(el);
const line = parseFloat(s.lineHeight) || 24;
const padY = parseFloat(s.paddingTop) + parseFloat(s.paddingBottom);
const borderY = parseFloat(s.borderTopWidth) + parseFloat(s.borderBottomWidth);
const max = line * maxRows + padY + borderY;
el.style.height = "auto"; // shrink first, so deleting text makes it smaller
const full = el.scrollHeight + borderY; // scrollHeight has the padding, not the border
el.style.height = `${Math.min(full, max)}px`;
el.style.overflowY = full > max ? "auto" : "hidden";
}, [ref, value, maxRows]);
}scrollHeight includes padding but not the border, and Tailwind sets every element to border-box, so the hook adds the border back. Without that the last line sits 2px under the edge and a scrollbar flickers in.
A chat composer that grows and sends on Enter
The usual AI chat input: Enter sends, Shift+Enter adds a line, and the box shrinks back to one row after sending because the value goes back to empty and the hook runs again.
"use client";
import { useRef, useState, type KeyboardEvent } from "react";
import { useAutosize } from "./useAutosize";
export function Composer({ onSend }: { onSend: (text: string) => void }) {
const [value, setValue] = useState("");
const ref = useRef<HTMLTextAreaElement>(null);
useAutosize(ref, value, 8);
const send = () => {
const text = value.trim();
if (!text) return;
onSend(text);
setValue(""); // the hook shrinks the box back to one row
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Enter sends, Shift+Enter adds a line. Skip while an IME is composing
// (Japanese, Chinese, Korean input), where Enter confirms a word.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
className="flex items-end gap-2 rounded-2xl border border-zinc-700 bg-zinc-900 p-2 focus-within:border-violet-300"
>
<label htmlFor="prompt" className="sr-only">Message</label>
<textarea
id="prompt"
ref={ref}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask anything"
className="min-h-10 flex-1 resize-none bg-transparent px-2 py-2 leading-6 text-zinc-100 outline-none placeholder:text-zinc-500"
/>
<button
type="submit"
disabled={!value.trim()}
className="h-10 shrink-0 rounded-xl bg-violet-300 px-4 text-sm font-medium text-zinc-950 transition active:scale-95 disabled:opacity-40"
>
Send
</button>
</form>
);
}The isComposing check matters for people typing Japanese, Chinese or Korean. Their Enter confirms a word, and without the check it sends a half written message. items-end keeps the Send button at the bottom as the box grows.
What we checked
In Chrome at 390px wide: the box starts at one row (40px), grows to 112px with four lines, stops at 208px with eight lines and scrolls inside, and drops back to 40px after Enter sends. The field-sizing version grew from 46px to 118px with four lines.
Using Cursor, v0 or Lovable
Cursor and v0 often write an auto resize with an onInput handler that sets style.height = scrollHeight. That grows but never shrinks when text is deleted. Ask them to reset the height to auto first, or paste the hook.
Lovable: the hook is plain React and Tailwind, so it drops into a Vite project as is.
The hook needs "use client" in the Next.js App Router because it touches the DOM.
Or use the ready-made piece
If this textarea is for an AI chat, Prompt Bloom is a finished animated composer: the prompt lifts into a message, it shows a Thinking pill until the first token, and the answer streams into a card. It works with the Vercel AI SDK's useChat: you pass the reply as it grows and a streaming flag.
Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.
Questions
- How do I auto resize a textarea in React?
- Set its height to auto, then to its scrollHeight, every time the value changes, inside useLayoutEffect. Cap it with a max height so long text scrolls inside.
- Can Tailwind auto resize a textarea without JavaScript?
- Yes, with the field-sizing-content class in browsers that support CSS field-sizing. Add max-h and overflow-y-auto so it stops growing.
- Why does my auto resize textarea not shrink?
- The height was never reset. Set style.height to auto before reading scrollHeight, or the old height keeps the box tall.
- How do I send on Enter but allow new lines?
- Handle onKeyDown: on Enter without Shift and when nativeEvent.isComposing is false, call preventDefault and send. Shift+Enter falls through and adds a line.