# Textarea

Grows with its content on a spring, with a ring that fills toward the character limit and warms near the end.

**Category:** Inputs  
**Exports:** `Textarea`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/textarea.json
```
The file lands at `components/ui/textarea.tsx`; import from `@/components/ui/textarea`.

## API — Textarea
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | `string` | — |  |
| `maxLength` | `number` | — |  |
| `minRows / maxRows` | `number` | `3 / 10` |  |
| `...props` | `ComponentProps<"textarea">` | — |  |

## Example — Textarea

```tsx
import { Textarea } from "@/components/ui/textarea";

export default function TextareaDemo({
  label = "Feedback",
  placeholder = "What should we build next?",
  maxLength = 200,
  minRows = 3,
}: {
  label?: string;
  placeholder?: string;
  maxLength?: number;
  minRows?: number;
}) {
  return (
    <div className="w-full max-w-md">
      <Textarea label={label} placeholder={placeholder} maxLength={maxLength} minRows={minRows} />
    </div>
  );
}
```

## Source — components/ui/textarea.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type TextareaProps = React.ComponentProps<"textarea"> & {
  label?: string;
  /** Character limit shown as a filling ring. */
  maxLength?: number;
  minRows?: number;
  maxRows?: number;
};

export function Textarea({ label, maxLength, minRows = 3, maxRows = 10, className, id: idProp, value, defaultValue, onChange, ...props }: TextareaProps) {
  const autoId = React.useId();
  const id = idProp ?? autoId;
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLTextAreaElement>(null);
  const [len, setLen] = React.useState(String(value ?? defaultValue ?? "").length);
  const [height, setHeight] = React.useState<number | "auto">("auto");

  const measure = React.useCallback(() => {
    const el = ref.current;
    if (!el) return;
    const line = parseFloat(getComputedStyle(el).lineHeight) || 22;
    const pad = 24;
    const prev = el.style.height;
    el.style.height = "auto";
    const h = Math.min(Math.max(el.scrollHeight, minRows * line + pad), maxRows * line + pad);
    el.style.height = prev;
    setHeight(h);
  }, [minRows, maxRows]);

  React.useLayoutEffect(measure, [measure, value]);

  const ratio = maxLength ? Math.min(1, len / maxLength) : 0;
  const left = maxLength ? maxLength - len : 0;
  const tone = ratio >= 1 ? "text-destructive" : ratio > 0.85 ? "text-ember" : "text-muted-foreground";

  return (
    <div className={cn("grid w-full gap-1.5", className)}>
      {label && (
        <label htmlFor={id} className="text-sm font-medium">
          {label}
        </label>
      )}
      <div className="relative rounded-lg border border-input bg-background transition-[border-color,box-shadow] focus-within:border-foreground/40 focus-within:ring-[3px] focus-within:ring-ring/25">
        <motion.textarea
          ref={ref}
          id={id}
          value={value}
          defaultValue={defaultValue}
          maxLength={maxLength}
          aria-describedby={maxLength ? `${id}-count` : undefined}
          onChange={(e) => {
            setLen(e.target.value.length);
            measure();
            onChange?.(e);
          }}
          animate={{ height }}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 36 }}
          className="block w-full resize-none bg-transparent px-3.5 py-3 text-base leading-[22px] outline-none placeholder:text-muted-foreground sm:text-sm"
          {...(props as object)}
        />
        {maxLength && (
          <div className={cn("flex items-center justify-end gap-2 px-3 pb-2.5 text-xs tabular-nums", tone)}>
            <span id={`${id}-count`} aria-live="polite">
              {left <= 20 ? `${left} left` : `${len}/${maxLength}`}
            </span>
            <svg viewBox="0 0 20 20" className="size-5 -rotate-90" aria-hidden>
              <circle cx="10" cy="10" r="8" fill="none" strokeWidth="2.5" className="stroke-muted" />
              <motion.circle
                cx="10"
                cy="10"
                r="8"
                fill="none"
                strokeWidth="2.5"
                strokeLinecap="round"
                className="stroke-current"
                initial={false}
                animate={{ pathLength: ratio }}
                transition={{ type: "spring", stiffness: 300, damping: 30 }}
              />
            </svg>
          </div>
        )}
      </div>
    </div>
  );
}
```