Textarea Autogrow
The field takes one line-height more as the text wraps, easing into it rather than snapping.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Textarea Autogrow
*
* The field takes exactly one line-height more when the text wraps, and
* eases into it instead of snapping. Height is measured from a hidden
* mirror of the same text, so the growth is right on the first frame and
* the caret never jumps.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so the field reads correctly on a
* light page and on a dark one.
* Works with zero props; tune via `variant`, `label`, `maxRows`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TextareaAutogrowProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label. Also the textarea's accessible name. */
label?: string;
/** Shown while the field is empty. */
placeholder?: string;
/** Starting text. */
defaultValue?: string;
/** Lines shown before the field stops growing and starts scrolling. */
maxRows?: number;
/** Lines at rest. */
minRows?: number;
/** Field width. */
width?: number | string;
/** Focus color. */
accent?: string;
/** Fires on every keystroke. */
onValueChange?: (value: string) => void;
};
type VariantConfig = {
/** Seconds for the field to take its new height. */
grow: number;
/** Deceleration curve for that growth. */
ease: [number, number, number, number];
};
// Quality rule: height is one of the few properties worth animating
// outright — the motion genuinely is a size change — so it runs as a
// short eased tween and nothing else moves with it. A spring here would
// overshoot the new height, and a field that springs past its own
// content is a field that flashes a clipped line of the user's text.
// Variants differ only in how long that single line-height takes.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Fast enough to read as a resize rather than an animation.
subtle: { grow: 0.08, ease: [0.22, 0.61, 0.36, 1] },
// The growth is visible without being waited on. All-purpose.
default: { grow: 0.17, ease: [0.22, 0.61, 0.36, 1] },
// A long, soft settle for a composer that is the centre of the screen.
playful: { grow: 0.3, ease: [0.16, 0.84, 0.44, 1] },
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface or border that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const FONT_SIZE = 13.5;
const LINE_HEIGHT = 21;
const PAD_Y = 10;
const PAD_X = 12;
/** Measuring before paint keeps the field from being seen at the wrong
* size for a frame. React warns about layout effects on the server, so
* fall back where there is no DOM to measure. */
const useMeasureEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
export default function TextareaAutogrow({
variant = "default",
label = "Add a comment",
placeholder = "Share an update with the team",
defaultValue = "",
maxRows = 6,
minRows = 2,
width = 300,
accent = "#5B5BD6",
onValueChange,
}: TextareaAutogrowProps) {
const [value, setValue] = useState(defaultValue);
const [focused, setFocused] = useState(false);
const minHeight = minRows * LINE_HEIGHT + PAD_Y * 2;
const maxHeight = maxRows * LINE_HEIGHT + PAD_Y * 2;
const [height, setHeight] = useState(minHeight);
const mirrorRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The mirror is the same text, in the same font, at the same width, so
// its height is the height the field wants — including the wrap the
// user is one character away from causing. Reading scrollHeight off
// the textarea itself would mean writing a height to it first, which
// fights whatever is animating it.
useMeasureEffect(() => {
const measured = mirrorRef.current?.offsetHeight ?? minHeight;
setHeight(Math.min(maxHeight, Math.max(minHeight, measured)));
}, [value, minHeight, maxHeight]);
const atCap = height >= maxHeight;
const sharedText = {
width: "100%",
boxSizing: "border-box" as const,
padding: `${PAD_Y}px ${PAD_X}px`,
fontSize: FONT_SIZE,
lineHeight: `${LINE_HEIGHT}px`,
fontFamily: "inherit",
letterSpacing: "normal",
whiteSpace: "pre-wrap" as const,
overflowWrap: "break-word" as const,
};
return (
<div style={{ width, color: "inherit" }}>
<label
htmlFor={`${baseId}-field`}
style={{
display: "block",
fontSize: 12.5,
fontWeight: 600,
opacity: 0.6,
marginBottom: 7,
}}
>
{label}
</label>
<div
style={{
position: "relative",
borderRadius: 11,
background: tone(6),
border: `1px solid ${focused ? accent : tone(14)}`,
boxShadow: focused ? `0 0 0 3px ${accent}33` : "none",
transition: "border-color 160ms ease-out, box-shadow 160ms ease-out",
}}
>
<motion.textarea
id={`${baseId}-field`}
value={value}
placeholder={placeholder}
rows={minRows}
aria-describedby={`${baseId}-hint`}
onChange={(event) => {
setValue(event.target.value);
onValueChange?.(event.target.value);
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
initial={false}
animate={{ height }}
transition={
reduceMotion ? { duration: 0 } : { duration: cfg.grow, ease: cfg.ease }
}
style={{
...sharedText,
display: "block",
// A textarea inherits neither the page's text color nor its
// font: both have to be asked for by name.
color: "inherit",
background: "transparent",
border: "none",
outline: "none",
// The field grows instead; the grab handle would let the user
// fight it. Past the cap it scrolls like any other field.
resize: "none",
overflowY: atCap ? "auto" : "hidden",
}}
/>
{/* The measuring mirror: same text, same metrics, out of the
accessibility tree and out of the paint. */}
<div
ref={mirrorRef}
aria-hidden
style={{
...sharedText,
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
pointerEvents: "none",
minHeight,
}}
>
{/* The trailing space gives a final newline something to
occupy, so pressing Enter grows the field immediately. */}
{value + " "}
</div>
</div>
<div
id={`${baseId}-hint`}
style={{ fontSize: 11.5, opacity: 0.45, marginTop: 7 }}
>
{atCap
? `Showing ${maxRows} lines. The rest scrolls.`
: "The field grows as the text wraps."}
</div>
</div>
);
}About this pattern
The height is read from a hidden mirror holding the same text in the same font at the same width, which is why the growth is correct on the frame the wrap happens rather than one frame late. Height is one of the few properties worth animating outright, since the motion genuinely is a size change — but it runs as a short eased tween and never a spring: a field that springs past its own height flashes a clipped line of the user's own writing. Past the row cap it stops growing and scrolls, and the hint says so.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
A field that grows to a cap and then scrolls, without the caret jumping.
Related patterns
- Conditional Section RevealPicking an option unfolds the extra fields it requires, the section easing its height open.
- Character Count LimitA small ring closes as the field fills, shows what is left once the cap is in sight, and tints once when the text runs past it.
- Form Reset ClearA wash crosses each row in turn and the value is dropped while the row is covered.