Textarea

Auto-growing multi-line input with an optional character counter.

import { Textarea }from "@/components/ui"

Specification

Collects multi-line text, growing to fit its content with an optional character counter.

Props (12)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
idstring—Explicit element id, wired to the label.
labelstring—Visible field label shown above the textarea.
hintstring—Helper text shown below the field when there is no error.
errorstring—Error message; renders the field in the error state.
requiredboolean—Marks the field required with a visual asterisk.
placeholderstring—Example input shown when empty.
maxLengthnumber—Character limit; drives the counter and native maxLength.
rowsnumber3Initial visible row count before auto-growing.
valuestring—Controlled textarea value.
onChange(e: ChangeEvent<HTMLTextAreaElement>) => void—Change handler; fires after any rule enforcement is applied.
onBlur(e: FocusEvent<HTMLTextAreaElement>) => void—Blur handler; fires after rule-based formatting on blur.
ruleInputRule—Keystroke/blur validation rule that strips disallowed characters, caps length, and pretty-prints on blur.

With counter

 

0/200
Full source
The complete component, read from src/components/ui/textarea.tsx.
textarea.tsx
"use client";

import { useRef, useEffect, useId, useCallback, type ChangeEvent, type FocusEvent } from "react";
import { cn } from "@/lib/utils";
import { Field, controlBase, controlClasses } from "./field";
import { enforceInputRule, formatOnBlur, type InputRule } from "@/lib/validation";

interface TextareaProps {
  id?: string;
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  placeholder?: string;
  maxLength?: number;
  rows?: number;
  value?: string;
  onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
  onBlur?: (e: FocusEvent<HTMLTextAreaElement>) => void;
  /**
   * Keystroke/blur validation rule (same model as `Input`). Strips disallowed
   * characters and caps length on change; pretty-prints via `rule.format` on
   * blur. See `@/lib/validation`.
   */
  rule?: InputRule;
}

export function Textarea({
  id,
  label,
  hint,
  error,
  required,
  placeholder,
  maxLength,
  rows = 3,
  value,
  onChange,
  onBlur,
  rule,
}: TextareaProps) {
  const ref = useRef<HTMLTextAreaElement>(null);
  const reactId = useId();
  const areaId = id ?? reactId;
  const messageId = `${areaId}-message`;
  const describedBy = error || hint ? messageId : undefined;
  const effectiveMaxLength = maxLength ?? rule?.maxLength;

  const handleChange = useCallback(
    (event: ChangeEvent<HTMLTextAreaElement>) => {
      if (rule) {
        const enforced = enforceInputRule(event.target.value, rule);
        if (enforced !== event.target.value) event.target.value = enforced;
      }
      onChange?.(event);
    },
    [rule, onChange]
  );

  const handleBlur = useCallback(
    (event: FocusEvent<HTMLTextAreaElement>) => {
      if (rule?.format) {
        const formatted = formatOnBlur(rule.format, event.target.value);
        if (formatted !== event.target.value) {
          event.target.value = formatted;
          onChange?.(event as unknown as ChangeEvent<HTMLTextAreaElement>);
        }
      }
      onBlur?.(event);
    },
    [rule, onChange, onBlur]
  );

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = Math.min(el.scrollHeight, 220) + "px";
  }, [value]);

  const len = (value || "").length;
  const counter = effectiveMaxLength
    ? { text: `${len}/${effectiveMaxLength}`, warn: len > effectiveMaxLength * 0.9 }
    : undefined;

  return (
    <Field
      label={label}
      hint={hint}
      error={error}
      required={required}
      htmlFor={areaId}
      counter={counter}
      messageId={messageId}
    >
      <textarea
        ref={ref}
        id={areaId}
        rows={rows}
        placeholder={placeholder}
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        maxLength={effectiveMaxLength}
        aria-invalid={Boolean(error) || undefined}
        aria-describedby={describedBy}
        className={cn(controlBase, controlClasses(error), "resize-y px-3 py-2 leading-relaxed")}
      />
    </Field>
  );
}

Dependencies

To use Textarea you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  ├─ field.tsx
  └─ textarea.tsx
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/components/ui/field.tsx
field.tsx
"use client";

import { type ReactNode } from "react";
import { cn } from "@/lib/utils";

interface FieldCounter {
  text: string;
  warn?: boolean;
}

interface FieldProps {
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  htmlFor?: string;
  counter?: FieldCounter;

  messageId?: string;
  children: ReactNode;
}

export function Field({
  label,
  hint,
  error,
  required,
  htmlFor,
  counter,
  messageId,
  children,
}: FieldProps) {
  const message = error || hint;
  return (
    <div className="flex w-full flex-col gap-1.5">
      {label && (
        <label
          htmlFor={htmlFor}
          className="flex items-center gap-1 text-[13px] font-medium text-text"
        >
          {label}
          {required && (
            <span className="text-error" aria-hidden>
              *
            </span>
          )}
        </label>
      )}
      {children}
      <div className="flex items-start justify-between gap-3">
        <p
          id={messageId}
          className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
        >
          {message || "\u00A0"}
        </p>
        {counter && (
          <span
            className={cn(
              "shrink-0 text-xs tabular-nums",
              counter.warn ? "text-error" : "text-text-tertiary"
            )}
          >
            {counter.text}
          </span>
        )}
      </div>
    </div>
  );
}

export const controlBase = cn(
  "w-full rounded-lg border text-sm bg-surface text-text transition-colors",
  "placeholder:text-text-tertiary",
  "focus:outline-none focus-visible:outline-none focus-visible:border-accent",
  "disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
  "read-only:bg-bg-secondary"
);

export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
  return cn(
    error ? "border-error focus-visible:border-error" : "border-border",
    readOnly && "read-only:bg-bg-secondary",
    disabled && "text-text-tertiary"
  );
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}