Textarea
Inputs & Forms

Textarea

An auto-growing textarea using field-sizing: content with a JS fallback, a limit-aware character count, and the house error shake with fading validation messages.

Install

npx shadcn@latest add @paragon/textarea

textarea.tsx

"use client";

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

export interface TextareaProps extends React.ComponentProps<"textarea"> {
  /** Visible label, wired via htmlFor. */
  label?: string;
  /** Helper text below the field. Hidden while an error is shown. */
  hint?: string;
  /** Error message. Turns the field red, fades the message in, shakes once. */
  error?: string;
  /** Grow with content. Uses `field-sizing: content` with a JS fallback. */
  autoGrow?: boolean;
  /** Show the character count. Defaults to true when maxLength is set. */
  showCount?: boolean;
  /**
   * Change this value to replay the shake even when the error message is
   * unchanged (e.g. increment on every failed submit).
   */
  shakeKey?: string | number;
}

/**
 * Auto-growing textarea. Prefers the native `field-sizing: content` and
 * falls back to a scrollHeight measure where unsupported. The character
 * count is tabular-nums and tightens color as the limit approaches; errors
 * shake once and fade their message in, matching the Input recipe.
 */
export function Textarea({
  label,
  hint,
  error,
  autoGrow = true,
  showCount,
  shakeKey,
  maxLength,
  className,
  id: idProp,
  rows = 3,
  onChange,
  disabled,
  "aria-describedby": ariaDescribedBy,
  ...props
}: TextareaProps) {
  const autoId = React.useId();
  const id = idProp ?? autoId;
  const messageId = `${id}-message`;
  const ref = React.useRef<HTMLTextAreaElement>(null);

  const initial =
    props.value ?? props.defaultValue ?? "";
  const [count, setCount] = React.useState(String(initial).length);
  const length =
    props.value !== undefined ? String(props.value).length : count;

  const [nativeSizing, setNativeSizing] = React.useState(true);
  React.useEffect(() => {
    setNativeSizing(
      typeof CSS !== "undefined" && CSS.supports("field-sizing", "content"),
    );
  }, []);

  const resize = React.useCallback(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${el.scrollHeight + 2}px`; // +2 for the border
  }, []);

  // JS fallback: measure on mount and whenever the (controlled) value changes.
  React.useLayoutEffect(() => {
    if (autoGrow && !nativeSizing) resize();
  }, [autoGrow, nativeSizing, resize, props.value]);

  const [shaking, setShaking] = React.useState(false);
  React.useEffect(() => {
    if (error) setShaking(true);
  }, [error, shakeKey]);

  const showsCount = (showCount ?? maxLength !== undefined) && !disabled;
  const ratio = maxLength ? length / maxLength : 0;
  const invalid = Boolean(error);
  const message = error ?? hint;

  return (
    <div className="w-full">
      <style href="paragon-textarea" precedence="paragon">{`
        @keyframes paragon-textarea-shake {
          0% { translate: 0; animation-timing-function: cubic-bezier(0.36, 0, 0.66, 0.2); }
          25% { translate: -6px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
          50% { translate: 5px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
          75% { translate: -3px 0; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
          100% { translate: 0; }
        }
        @keyframes paragon-textarea-message-in {
          from { opacity: 0; translate: 0 -2px; filter: blur(2px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .paragon-textarea-shake { animation: none !important; }
          .paragon-textarea-message { animation: none !important; }
        }
      `}</style>

      {label && (
        <label
          htmlFor={id}
          className="mb-1.5 block text-sm font-medium text-foreground"
        >
          {label}
        </label>
      )}
      <div
        className={cn(shaking && "paragon-textarea-shake")}
        style={
          shaking
            ? { animation: "paragon-textarea-shake 280ms both" }
            : undefined
        }
        onAnimationEnd={(event) => {
          if (event.animationName === "paragon-textarea-shake") {
            setShaking(false);
          }
        }}
      >
        <textarea
          ref={ref}
          id={id}
          rows={rows}
          maxLength={maxLength}
          disabled={disabled}
          aria-invalid={invalid || undefined}
          aria-describedby={
            cn(message ? messageId : undefined, ariaDescribedBy) || undefined
          }
          onChange={(event) => {
            if (props.value === undefined) setCount(event.target.value.length);
            if (autoGrow && !nativeSizing) resize();
            onChange?.(event);
          }}
          className={cn(
            "block w-full min-w-0 resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm text-foreground",
            "transition-[border-color,box-shadow,background-color] duration-150 ease-out",
            "placeholder:text-muted-foreground",
            "outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
            "read-only:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50",
            invalid &&
              "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
            !invalid && "hover:not-disabled:not-focus-visible:border-ring/60",
            className,
          )}
          style={
            autoGrow
              ? ({ fieldSizing: "content" } as React.CSSProperties)
              : undefined
          }
          {...props}
        />
      </div>
      {(message || showsCount) && (
        <div className="mt-1.5 flex items-baseline justify-between gap-4">
          <p
            id={messageId}
            role={invalid ? "alert" : undefined}
            // Keyed so a new message re-runs the fade-in; the shake is a
            // separate class on the field wrapper, so replaying it never
            // flickers this element.
            key={message ?? "hint"}
            className={cn(
              "paragon-textarea-message min-w-0 text-[13px]",
              invalid ? "text-destructive" : "text-muted-foreground",
            )}
            style={
              message
                ? {
                    animation:
                      "paragon-textarea-message-in 200ms var(--ease-out) both",
                  }
                : undefined
            }
          >
            {message}
          </p>
          {showsCount && (
            <span
              aria-hidden
              className={cn(
                "shrink-0 text-xs tabular-nums transition-colors duration-150 ease-out",
                ratio >= 1
                  ? "text-destructive"
                  : ratio >= 0.85
                    ? "text-warning"
                    : "text-muted-foreground/70",
              )}
            >
              {maxLength !== undefined ? `${length}/${maxLength}` : length}
            </span>
          )}
        </div>
      )}
    </div>
  );
}