# Textarea Autosize

> Textarea that grows with its content up to a max row count, with an optional character counter.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/textarea-autosize
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/textarea-autosize.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/textarea-autosize-demo.tsx"
"use client"

import { TextareaAutosize } from "@/components/textarea-autosize"

export default function TextareaAutosizeDemo() {
  return (
    <div className="grid w-full max-w-md gap-2">
      <label htmlFor="bio" className="text-sm font-medium">
        Bio
      </label>
      <TextareaAutosize
        id="bio"
        minRows={2}
        maxRows={6}
        maxLength={240}
        showCount
        placeholder="Tell us a little about yourself. The box grows as you type, up to six lines."
      />
    </div>
  )
}
```

## Source

```tsx title="components/textarea-autosize.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface TextareaAutosizeProps extends Omit<React.ComponentProps<"textarea">, "rows"> {
  minRows?: number
  maxRows?: number
  /** Show a live character counter (needs maxLength) */
  showCount?: boolean
}

export function TextareaAutosize({
  minRows = 2,
  maxRows = 8,
  showCount = false,
  maxLength,
  value,
  defaultValue,
  onChange,
  className,
  ...props
}: TextareaAutosizeProps) {
  const ref = React.useRef<HTMLTextAreaElement>(null)
  const [internal, setInternal] = React.useState(String(defaultValue ?? ""))
  const text = value === undefined ? internal : String(value)

  React.useLayoutEffect(() => {
    const el = ref.current
    if (!el) return
    const style = getComputedStyle(el)
    const line = parseFloat(style.lineHeight) || 20
    const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
    const border = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth)
    el.style.height = "auto"
    const wanted = el.scrollHeight + border
    const min = line * minRows + padding + border
    const max = line * maxRows + padding + border
    el.style.height = `${Math.min(max, Math.max(min, wanted))}px`
    el.style.overflowY = wanted > max ? "auto" : "hidden"
  }, [text, minRows, maxRows])

  return (
    <div className="relative w-full">
      <textarea
        {...props}
        ref={ref}
        rows={minRows}
        maxLength={maxLength}
        value={value}
        defaultValue={defaultValue}
        onChange={(event) => {
          setInternal(event.target.value)
          onChange?.(event)
        }}
        className={cn(
          "block w-full resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm leading-6 outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
          showCount && "pb-7",
          className,
        )}
      />
      {showCount && (
        <span
          aria-live="off"
          className={cn(
            "pointer-events-none absolute bottom-2 right-3 text-xs tabular-nums text-muted-foreground",
            maxLength && text.length >= maxLength && "text-destructive",
          )}
        >
          {text.length}
          {maxLength ? ` / ${maxLength}` : ""}
        </span>
      )}
    </div>
  )
}
```
