React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

textareaformmulti-linetext-inputcomponenttailwind

Textarea

A multi-line text input for capturing longer content such as comments, descriptions, or messages. Shares styling conventions with the Input component for visual consistency across forms.

Use Cases

  • Comment and feedback forms
  • Bio or description fields on profile pages
  • Message composition in chat or email interfaces
  • Code or JSON input fields in developer tools
  • Notes or memo fields in dashboards
  • Support ticket descriptions
  • Content editing areas in CMS interfaces

Simplest Implementation

"use client";
 
interface TextareaProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
}
 
export function Textarea({ label, value, onChange }: TextareaProps) {
  return (
    <label className="block">
      <span className="text-sm font-medium text-gray-700">{label}</span>
      <textarea
        value={value}
        onChange={(e) => onChange(e.target.value)}
        rows={4}
        className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
      />
    </label>
  );
}

A minimal labeled textarea. The onChange callback returns the string value directly so the parent does not need to unwrap e.target.value. Wrapping the textarea inside a <label> element associates the label with the field automatically.

Variations

With Label and Error

"use client";
 
interface TextareaProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
  error?: string;
}
 
export function Textarea({ label, value, onChange, error }: TextareaProps) {
  return (
    <label className="block">
      <span className="text-sm font-medium text-gray-700">{label}</span>
      <textarea
        value={value}
        onChange={(e) => onChange(e.target.value)}
        rows={4}
        aria-invalid={!!error}
        className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 ${
          error
            ? "border-red-500 focus:border-red-500 focus:ring-red-500"
            : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
        }`}
      />
      {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
    </label>
  );
}

Toggles border and ring colors to red when an error message is present. The aria-invalid attribute tells screen readers the field has a validation problem.

Auto-Resize

"use client";
 
import { useRef, useCallback } from "react";
 
interface AutoResizeTextareaProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
  minRows?: number;
}
 
export function AutoResizeTextarea({
  label,
  value,
  onChange,
  minRows = 3,
}: AutoResizeTextareaProps) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);
 
  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
      onChange(e.target.value);
      const el = e.target;
      el.style.height = "auto";
      el.style.height = `${el.scrollHeight}px`;
    },
    [onChange]
  );
 
  return (
    <label className="block">
      <span className="text-sm font-medium text-gray-700">{label}</span>
      <textarea
        ref={textareaRef}
        value={value}
        onChange={handleChange}
        rows={minRows}
        className="mt-1 block w-full resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
      />
    </label>
  );
}

The textarea grows as the user types by resetting its height to auto then setting it to scrollHeight on every change. The resize-none class disables the manual drag handle since height is managed programmatically.

Character Count

"use client";
 
interface TextareaWithCountProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
  maxLength: number;
}
 
export function TextareaWithCount({
  label,
  value,
  onChange,
  maxLength,
}: TextareaWithCountProps) {
  const remaining = maxLength - value.length;
 
  return (
    <label className="block">
      <span className="text-sm font-medium text-gray-700">{label}</span>
      <textarea
        value={value}
        onChange={(e) => onChange(e.target.value)}
        maxLength={maxLength}
        rows={4}
        className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
      />
      <p
        className={`mt-1 text-right text-xs ${
          remaining < 20 ? "text-red-500" : "text-gray-400"
        }`}
      >
        {value.length}/{maxLength}
      </p>
    </label>
  );
}

Displays a live character count below the textarea. The counter turns red when fewer than 20 characters remain, giving the user a visual warning before hitting the limit. The native maxLength attribute prevents exceeding the maximum.

With Helper Text

"use client";
 
interface TextareaProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
  helperText?: string;
  error?: string;
}
 
export function Textarea({ label, value, onChange, helperText, error }: TextareaProps) {
  return (
    <label className="block">
      <span className="text-sm font-medium text-gray-700">{label}</span>
      <textarea
        value={value}
        onChange={(e) => onChange(e.target.value)}
        rows={4}
        aria-invalid={!!error}
        className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 ${
          error
            ? "border-red-500 focus:border-red-500 focus:ring-red-500"
            : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
        }`}
      />
      {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
      {!error && helperText && <p className="mt-1 text-sm text-gray-500">{helperText}</p>}
    </label>
  );
}

Helper text appears below the textarea when there is no error. When an error is present it takes priority, preventing conflicting messages from stacking.

Disabled State

"use client";
 
interface TextareaProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
  disabled?: boolean;
}
 
export function Textarea({ label, value, onChange, disabled = false }: TextareaProps) {
  return (
    <label className="block">
      <span
        className={`text-sm font-medium ${disabled ? "text-gray-400" : "text-gray-700"}`}
      >
        {label}
      </span>
      <textarea
        value={value}
        onChange={(e) => onChange(e.target.value)}
        disabled={disabled}
        rows={4}
        className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500"
      />
    </label>
  );
}

The disabled prop prevents editing and applies muted styling via Tailwind disabled: variants. The label also dims to reinforce the inactive state.

Controlled with Form Action

"use client";
 
import { useActionState } from "react";
 
interface FormState {
  message: string;
  error?: string;
}
 
async function submitFeedback(
  _prev: FormState,
  formData: FormData
): Promise<FormState> {
  const content = formData.get("content") as string;
  if (!content || content.trim().length < 10) {
    return { message: "", error: "Feedback must be at least 10 characters." };
  }
  // Simulate server submission
  return { message: "Thank you for your feedback!" };
}
 
export function FeedbackForm() {
  const [state, formAction, isPending] = useActionState(submitFeedback, {
    message: "",
  });
 
  return (
    <form action={formAction} className="space-y-4">
      <label className="block">
        <span className="text-sm font-medium text-gray-700">Your Feedback</span>
        <textarea
          name="content"
          rows={5}
          required
          className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 ${
            state.error
              ? "border-red-500 focus:border-red-500 focus:ring-red-500"
              : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
          }`}
        />
        {state.error && <p className="mt-1 text-sm text-red-600">{state.error}</p>}
      </label>
      <button
        type="submit"
        disabled={isPending}
        className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? "Submitting..." : "Submit"}
      </button>
      {state.message && <p className="text-sm text-green-600">{state.message}</p>}
    </form>
  );
}

Uses React 19 useActionState to handle form submission with server-side validation. The textarea is uncontrolled via name attribute, letting the browser handle FormData collection. The isPending flag disables the submit button during async processing.

Complex Implementation

"use client";
 
import { forwardRef, useId, useRef, useCallback, useEffect } from "react";
 
type TextareaSize = "sm" | "md" | "lg";
 
interface TextareaProps
  extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange"> {
  label?: string;
  helperText?: string;
  error?: string;
  size?: TextareaSize;
  maxLength?: number;
  showCount?: boolean;
  autoResize?: boolean;
  onValueChange?: (value: string) => void;
  onChange?: React.ChangeEventHandler<HTMLTextAreaElement>;
  fullWidth?: boolean;
}
 
const sizeClasses: Record<TextareaSize, { input: string; text: string }> = {
  sm: { input: "px-2.5 py-1.5 text-xs", text: "text-xs" },
  md: { input: "px-3 py-2 text-sm", text: "text-sm" },
  lg: { input: "px-4 py-3 text-base", text: "text-base" },
};
 
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
  function Textarea(
    {
      label,
      helperText,
      error,
      size = "md",
      maxLength,
      showCount = false,
      autoResize = false,
      onValueChange,
      onChange,
      fullWidth = true,
      disabled,
      className,
      id: externalId,
      value,
      defaultValue,
      rows = 4,
      ...rest
    },
    ref
  ) {
    const generatedId = useId();
    const inputId = externalId ?? generatedId;
    const errorId = `${inputId}-error`;
    const helperId = `${inputId}-helper`;
    const internalRef = useRef<HTMLTextAreaElement>(null);
 
    const textareaRef = (ref as React.RefObject<HTMLTextAreaElement>) ?? internalRef;
 
    const adjustHeight = useCallback(() => {
      const el = textareaRef.current;
      if (!el || !autoResize) return;
      el.style.height = "auto";
      el.style.height = `${el.scrollHeight}px`;
    }, [autoResize, textareaRef]);
 
    useEffect(() => {
      adjustHeight();
    }, [value, adjustHeight]);
 
    const handleChange = useCallback(
      (e: React.ChangeEvent<HTMLTextAreaElement>) => {
        onChange?.(e);
        onValueChange?.(e.target.value);
        if (autoResize) {
          e.target.style.height = "auto";
          e.target.style.height = `${e.target.scrollHeight}px`;
        }
      },
      [onChange, onValueChange, autoResize]
    );
 
    const hasError = !!error;
    const sizes = sizeClasses[size];
    const currentLength =
      typeof value === "string"
        ? value.length
        : typeof defaultValue === "string"
          ? defaultValue.length
          : 0;
 
    return (
      <div className={fullWidth ? "w-full" : "inline-flex flex-col"}>
        {label && (
          <label
            htmlFor={inputId}
            className={`mb-1 block font-medium text-gray-700 ${sizes.text}`}
          >
            {label}
          </label>
        )}
        <textarea
          ref={textareaRef}
          id={inputId}
          disabled={disabled}
          value={value}
          defaultValue={defaultValue}
          onChange={handleChange}
          rows={rows}
          maxLength={maxLength}
          aria-invalid={hasError}
          aria-describedby={
            [hasError ? errorId : null, helperText ? helperId : null]
              .filter(Boolean)
              .join(" ") || undefined
          }
          className={[
            "block rounded-lg border shadow-sm transition-colors",
            "focus:outline-none focus:ring-1",
            "disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500",
            "placeholder:text-gray-400",
            autoResize ? "resize-none overflow-hidden" : "resize-y",
            sizes.input,
            fullWidth ? "w-full" : "",
            hasError
              ? "border-red-500 focus:border-red-500 focus:ring-red-500"
              : "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
            className ?? "",
          ]
            .filter(Boolean)
            .join(" ")}
          {...rest}
        />
        <div className="mt-1 flex items-start justify-between gap-2">
          <div>
            {hasError && (
              <p id={errorId} className={`text-red-600 ${sizes.text}`} role="alert">
                {error}
              </p>
            )}
            {!hasError && helperText && (
              <p id={helperId} className={`text-gray-500 ${sizes.text}`}>
                {helperText}
              </p>
            )}
          </div>
          {showCount && maxLength && (
            <p
              className={`shrink-0 ${sizes.text} ${
                currentLength >= maxLength * 0.9 ? "text-red-500" : "text-gray-400"
              }`}
            >
              {currentLength}/{maxLength}
            </p>
          )}
        </div>
      </div>
    );
  }
);

Key aspects:

  • forwardRef -- allows parent components to attach refs for focus management, form libraries, or imperative validation.
  • useId for accessibility -- generates a stable unique ID to link <label>, aria-describedby, and error/helper text without requiring the consumer to provide IDs.
  • Dual onChange API -- supports both the native onChange event handler and a simplified onValueChange string callback, making it compatible with form libraries and simple state alike.
  • Auto-resize with overflow hidden -- when autoResize is enabled, the textarea grows to fit content by resetting height to auto then setting it to scrollHeight, with resize-none and overflow-hidden to prevent visual artifacts.
  • Character count with threshold warning -- the counter turns red at 90% of maxLength, giving users early warning before hitting the limit.
  • aria-describedby chaining -- links the textarea to both error and helper text elements so screen readers announce contextual information in the correct order.
  • Disabled styling -- uses disabled:cursor-not-allowed and a muted background so the disabled state is visually obvious without relying on opacity alone.

Gotchas

  • resize-y vs resize-none confusion -- Using resize-y allows vertical resizing but conflicts with auto-resize behavior. When using programmatic height adjustment, always pair it with resize-none to prevent the drag handle from fighting the script.

  • scrollHeight requires auto height first -- Setting el.style.height = el.scrollHeight + "px" without first resetting to auto causes the textarea to only grow, never shrink. Always reset to auto before reading scrollHeight.

  • Uncontrolled to controlled warning -- Starting with value={undefined} then switching to a string triggers a React warning. Always initialize state as an empty string when using controlled mode.

  • maxLength does not validate on paste in all browsers -- Some older browsers allow pasting beyond maxLength. Always validate length server-side or in your submit handler as a fallback.

  • rows prop ignored with auto-resize -- When auto-resize is active, the rows attribute only sets the initial height. After the first keystroke, programmatic height takes over. Set a min-height in Tailwind if you need a guaranteed minimum.

  • Form data serialization -- Textareas preserve newlines as \r\n in form data on Windows. Normalize with .replace(/\r\n/g, "\n") if consistent line endings matter for your backend.

  • Input -- Single-line text input with shared styling conventions
  • Button -- Submit buttons used alongside textareas in forms
  • Forms -- Form patterns and validation strategies
  • Event Handling -- onChange and event typing