# Autosize Textarea

A textarea that grows with its content between row bounds.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/autosize-textarea.json
```

[Registry JSON](https://mwui.vercel.app/r/autosize-textarea.json)

## Preview

```tsx
import * as React from "react";

import { AutosizeTextarea } from "@/components/ui/autosize-textarea";

export function Preview() {
  const [message, setMessage] = React.useState(
    "Type a few lines here and watch the field grow.\nIt stops after six rows and scrolls instead.",
  );

  return (
    <div className="flex w-full max-w-sm flex-col gap-2 text-sm">
      <AutosizeTextarea
        value={message}
        onChange={(event) => setMessage(event.target.value)}
        minRows={2}
        maxRows={6}
        placeholder="Write a message…"
      />

      <p className="text-xs text-muted-foreground">
        {message.length} characters · {message.split("\n").length} lines
      </p>
    </div>
  );
}
```


## Source

### ui/autosize-textarea.tsx

```tsx
"use client";

import * as React from "react";

import { ScrollArea } from "@/components/ui/scroll-area";
import { useResizeObserver } from "@/hooks/use-resize-observer";
import { cn } from "@/lib/utils";

type AutosizeTextareaProps = Omit<React.ComponentProps<"textarea">, "rows"> & {
  minRows?: number;
  maxRows?: number;
  scrollArea?: boolean;
  onHeightChange?: (height: number) => void;
};

function AutosizeTextarea({
  minRows = 2,
  maxRows,
  scrollArea = true,
  onHeightChange,
  className,
  ref,
  value,
  defaultValue,
  onChange,
  ...props
}: AutosizeTextareaProps) {
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { ref: observerRef, width } = useResizeObserver<HTMLTextAreaElement>();

  const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
  const resolvedValue = value ?? internalValue;

  const scrolled = scrollArea && maxRows !== undefined;

  const onHeightChangeRef = React.useRef(onHeightChange);

  React.useEffect(() => {
    onHeightChangeRef.current = onHeightChange;
  });

  const setRefs = React.useCallback(
    (node: HTMLTextAreaElement | null) => {
      textareaRef.current = node;
      observerRef(node);

      if (typeof ref === "function") {
        ref(node);
      } else if (ref) {
        ref.current = node;
      }
    },
    [observerRef, ref],
  );

  React.useLayoutEffect(() => {
    const textarea = textareaRef.current;
    if (!textarea) return;

    const styles = getComputedStyle(textarea);
    const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
    const padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom);
    const border =
      Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth);

    textarea.style.height = "auto";

    const contentHeight = textarea.scrollHeight + border;
    const minHeight = minRows * lineHeight + padding + border;
    const maxHeight = maxRows === undefined ? Infinity : maxRows * lineHeight + padding + border;

    const height = scrolled
      ? Math.max(contentHeight, minHeight)
      : Math.min(Math.max(contentHeight, minHeight), maxHeight);

    textarea.style.height = `${height}px`;
    textarea.style.overflowY = !scrolled && contentHeight > maxHeight ? "auto" : "hidden";

    const container = containerRef.current;

    if (container) {
      // The viewport is the element that actually scrolls, so the row cap belongs on it.
      const viewport =
        container.querySelector<HTMLElement>("[data-slot=scroll-area-viewport]") ?? container;

      viewport.style.maxHeight = `${maxHeight}px`;
      container.style.maxHeight = `${maxHeight}px`;
    }

    onHeightChangeRef.current?.(Math.min(height, maxHeight));
  }, [resolvedValue, width, minRows, maxRows, scrolled]);

  const field = (
    <textarea
      ref={setRefs}
      data-slot="autosize-textarea"
      value={value}
      defaultValue={defaultValue}
      rows={minRows}
      onChange={(event) => {
        if (value === undefined) setInternalValue(event.target.value);
        onChange?.(event);
      }}
      className={cn(
        "block w-full resize-none bg-transparent px-2.5 py-1.5 text-base outline-none",
        "placeholder:text-muted-foreground md:text-sm",
        scrolled
          ? "disabled:cursor-not-allowed"
          : [
              "rounded-lg border border-input transition-colors dark:bg-input/30",
              "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
              "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
              "aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
            ],
        // The container owns the frame once it exists, so `className` styles that instead.
        !scrolled && className,
      )}
      {...props}
    />
  );

  if (!scrolled) return field;

  return (
    <ScrollArea
      ref={containerRef}
      className={cn(
        "w-full rounded-lg border border-input bg-transparent transition-colors dark:bg-input/30",
        "focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
        "has-[textarea:disabled]:pointer-events-none has-[textarea:disabled]:opacity-50",
        "has-[textarea[aria-invalid=true]]:border-destructive has-[textarea[aria-invalid=true]]:ring-3 has-[textarea[aria-invalid=true]]:ring-destructive/20",
        className,
      )}
    >
      {field}
    </ScrollArea>
  );
}

export { AutosizeTextarea, type AutosizeTextareaProps };
```



## Usage

A textarea that starts at a comfortable size, grows as the message gets longer, and stops growing
before it eats the page. The interaction every composer, comment box, and prompt field needs.

```tsx
import { AutosizeTextarea } from "@/components/ui/autosize-textarea";

export function Composer() {
  const [message, setMessage] = React.useState("");

  return (
    <AutosizeTextarea
      value={message}
      onChange={(event) => setMessage(event.target.value)}
      minRows={2}
      maxRows={10}
      placeholder="Write a message…"
    />
  );
}
```

Past `maxRows` the field stops growing and scrolls internally. Without a maximum it grows without
limit, which is fine inside a page but wrong inside a fixed composer.

It is a real `<textarea>` — every prop, `ref`, and form behavior passes straight through — so
`required`, `maxLength`, `name`, and controlled or uncontrolled use all work as usual.

## Scrolling past `maxRows`

A `<textarea>` is its own scroll container, so capping its height normally hands you the native
scrollbar — the one piece of a styled form that still looks like the operating system. Instead the
field is allowed to grow to its full content height and a [ScrollArea](https://ui.shadcn.com/docs/components/scroll-area)
clips it to the row cap, so the overflow gets the same overlay scrollbar as the rest of the app.

The frame — border, background, focus ring, invalid state — moves to that container, and `className`
follows it, so styling the component still means styling the box you can see. Typing still keeps the
caret in view: the browser scrolls the nearest scrollable ancestor, which is now the viewport.

```tsx
<AutosizeTextarea maxRows={6} scrollArea={false} />
```

Pass `scrollArea={false}` for the native scrollbar and a plain `<textarea>` with no wrapper — worth
it if you are dropping the field into something that already measures or scrolls its children. The
prop does nothing without `maxRows`, since an unbounded field never scrolls.

## Why not `field-sizing: content`

The CSS property does this natively in one line, and it is the right answer once support is
universal. Today it is missing in Safari and Firefox, where the field would silently stop resizing.
This measures instead, so it behaves the same everywhere.

## Resizing when the width changes

Height depends on width: a sidebar collapsing, a modal opening, or a window resize can rewrap the
text and change how many lines it takes. The component observes its own width with
[useResizeObserver](/utilities/use-resize-observer) and re-measures — which a value-only effect
would miss, leaving a field that is suddenly too short.

## Props

| Prop             | Description                                                        |
| ---------------- | ------------------------------------------------------------------ |
| `minRows`        | Starting height in rows. Defaults to `2`.                           |
| `maxRows`        | Stop growing after this many rows and scroll. Omit for no limit.     |
| `scrollArea`     | Scroll the overflow inside a `ScrollArea` instead of the native textarea scrollbar. Defaults to `true`. Needs `maxRows`. |
| `onHeightChange` | Called with the new pixel height — useful for keeping a scroll container pinned to the bottom. |

Everything else is forwarded to the underlying `textarea`.

## Behavior

- Measurement runs in a layout effect, before paint, so the height never flickers.
- `line-height` and padding are read from the computed style, so the row math follows your CSS
  instead of a hardcoded number.
- Manual resizing is disabled, since the drag handle would fight the automatic height.
- `onHeightChange` reports the visible height, so it stops at `maxRows` rather than reporting the
  full content height of a scrolled field.

