# Text Area

> A flexible writing field for notes, prompts, and support flows.

A shadcn/ui block from the [@wensity](https://ui.wensity.com/components) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/wensity/text-area).

- Page, with a live preview: https://shadcn.exchange/r/wensity/text-area
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/wensity/text-area-light-9588cc8c3f5e3a16.png
- Registry item: https://raw.githubusercontent.com/wensity/registry/main/text-area.json
- Categories: UI Primitives, wensity, free
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://raw.githubusercontent.com/wensity/registry/main/text-area.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tabler/icons-react`, `clsx`, `tailwind-merge`

## Notes from the registry

Free Wensity component. Installs to @components/wensity/text-area.tsx. For Pro components and updates, use pnpm dlx wensity@latest add text-area.

## Files

### `registry/wensity/lib/utils.ts`

```ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

### `registry/wensity/text-area.tsx`

```tsx
"use client";

import * as React from "react";
import { IconFileText } from "@tabler/icons-react";
import { cn } from "@/lib/utils";

export type TextareaSize = "sm" | "md" | "lg";
export type TextareaVariant = "default" | "filled" | "ghost";
export type TextareaResize = "none" | "vertical" | "both";

export interface TextareaProps
  extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
  label?: React.ReactNode;
  hint?: React.ReactNode;
  error?: React.ReactNode;
  textareaSize?: TextareaSize;
  variant?: TextareaVariant;
  resize?: TextareaResize;
  autoResize?: boolean;
  minRows?: number;
  maxRows?: number;
  showCount?: boolean;
  fullWidth?: boolean;
  containerClassName?: string;
  textareaClassName?: string;
}

const textareaSizes: Record<TextareaSize, string> = {
  sm: "min-h-[5.5rem] rounded-[var(--primitive-radius-control)] px-4 py-3 text-xs leading-5",
  md: "min-h-[7.5rem] rounded-[var(--primitive-radius-control)] px-4 py-4 text-sm leading-6",
  lg: "min-h-[9.5rem] rounded-[var(--primitive-radius-control)] px-5 py-4 text-[15px] leading-6",
};

const countPadding: Record<TextareaSize, string> = {
  sm: "pb-9",
  md: "pb-10",
  lg: "pb-11",
};

const resizeClasses: Record<TextareaResize, string> = {
  none: "resize-none",
  vertical: "resize-y",
  both: "resize",
};

const variantClasses: Record<TextareaVariant, string> = {
  default: cn(
    "border-[var(--border)] bg-[var(--background)]",
    "hover:border-[color-mix(in_srgb,var(--foreground)_14%,transparent)]",
  ),
  filled: cn(
    "border-[var(--border)]",
    "bg-[var(--muted)]",
    "hover:border-[color-mix(in_srgb,var(--foreground)_14%,transparent)]",
    "hover:bg-[var(--primitive-surface-hover)]",
    "active:bg-[var(--primitive-surface-active)]",
  ),
  ghost: cn(
    "border-transparent bg-transparent",
    "hover:border-[color-mix(in_srgb,var(--foreground)_10%,transparent)]",
    "hover:bg-[var(--primitive-surface-hover)]",
  ),
};

const focusClasses = cn(
  "focus:border-[color-mix(in_srgb,var(--foreground)_24%,transparent)]",
  "focus:ring-2 focus:ring-[var(--primitive-ring)] focus:ring-offset-2 focus:ring-offset-[var(--background)]",
);

const errorClasses = cn(
  "border-[var(--primitive-destructive-border)]",
  "hover:border-[color-mix(in_srgb,var(--primitive-destructive)_65%,transparent)]",
  "focus:border-[var(--primitive-destructive)]",
  "focus:ring-2 focus:ring-[color-mix(in_srgb,var(--primitive-destructive)_45%,transparent)] focus:ring-offset-2 focus:ring-offset-[var(--background)]",
);

const disabledClasses = cn(
  "cursor-not-allowed opacity-45",
  "hover:border-[var(--border)]",
);

const readOnlyClasses = cn(
  "border-[var(--border)] bg-[var(--muted)]",
  "text-[var(--muted-foreground)]",
  "hover:border-[var(--border)]",
);

function rowsToHeight(rows: number) {
  return `${Math.max(rows, 1) * 24 + 32}px`;
}

export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
  (
    {
      id,
      className,
      containerClassName,
      textareaClassName,
      label,
      hint,
      error,
      textareaSize = "md",
      variant = "default",
      resize = "vertical",
      autoResize = false,
      minRows = textareaSize === "sm" ? 3 : textareaSize === "lg" ? 5 : 4,
      maxRows,
      showCount = false,
      fullWidth = true,
      disabled,
      readOnly,
      value,
      defaultValue,
      maxLength,
      onChange,
      "aria-describedby": ariaDescribedBy,
      style,
      ...props
    },
    ref,
  ) => {
    const reactId = React.useId();
    const textareaId = id ?? `wensity-textarea-${reactId}`;
    const hintId = hint ? `${textareaId}-hint` : undefined;
    const errorId = error ? `${textareaId}-error` : undefined;
    const countId = showCount ? `${textareaId}-count` : undefined;
    const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
    const isControlled = value !== undefined;
    const [uncontrolledValue, setUncontrolledValue] = React.useState(
      defaultValue?.toString() ?? "",
    );
    const currentValue = isControlled ? value : uncontrolledValue;
    const currentLength = currentValue == null ? 0 : String(currentValue).length;
    const describedBy =
      [ariaDescribedBy, errorId, hintId, countId].filter(Boolean).join(" ") ||
      undefined;

    const setRefs = React.useCallback(
      (node: HTMLTextAreaElement | null) => {
        textareaRef.current = node;
        if (typeof ref === "function") {
          ref(node);
        } else if (ref) {
          ref.current = node;
        }
      },
      [ref],
    );

    const syncHeight = React.useCallback(() => {
      const node = textareaRef.current;
      if (!node || !autoResize) return;

      node.style.height = "auto";
      const maxHeight = maxRows ? Number.parseFloat(rowsToHeight(maxRows)) : Infinity;
      node.style.height = `${Math.min(node.scrollHeight, maxHeight)}px`;
      node.style.overflowY = node.scrollHeight > maxHeight ? "auto" : "hidden";
    }, [autoResize, maxRows]);

    const handleChange = React.useCallback(
      (event: React.ChangeEvent<HTMLTextAreaElement>) => {
        if (!isControlled) setUncontrolledValue(event.target.value);
        onChange?.(event);
        window.requestAnimationFrame(syncHeight);
      },
      [isControlled, onChange, syncHeight],
    );

    React.useLayoutEffect(() => {
      syncHeight();
    }, [currentValue, syncHeight]);

    return (
      <div data-wensity-primitive=""
        className={cn(
          "flex flex-col gap-1.5",
          fullWidth ? "w-full" : "w-fit",
          containerClassName,
        )}
      >
        {label ? (
          <label
            htmlFor={textareaId}
            className="text-[13px] font-medium leading-none tracking-[-0.01em] text-[var(--foreground)]"
          >
            {label}
          </label>
        ) : null}

        <div className={cn("relative", fullWidth ? "w-full" : "w-fit", className)}>
          <textarea
            ref={setRefs}
            id={textareaId}
            value={isControlled ? value : uncontrolledValue}
            disabled={disabled}
            readOnly={readOnly}
            maxLength={maxLength}
            rows={autoResize ? minRows : props.rows ?? minRows}
            aria-invalid={error ? true : undefined}
            aria-describedby={describedBy}
            onChange={handleChange}
            data-wensity-textarea=""
            data-error={error ? "" : undefined}
            data-readonly={readOnly ? "" : undefined}
            data-disabled={disabled ? "" : undefined}
            className={cn(
              "wensity-textarea-control w-full border outline-none",
              "text-[var(--foreground)] placeholder:text-[var(--primitive-text-placeholder)]",
              "transition-[border-color,background-color,box-shadow] duration-150 ease-[var(--primitive-ease)]",
              "motion-reduce:transition-none",
              "disabled:cursor-not-allowed",
              textareaSizes[textareaSize],
              showCount && countPadding[textareaSize],
              !autoResize && resizeClasses[resize],
              autoResize && "resize-none overflow-hidden",
              variantClasses[variant],
              !disabled && !readOnly && !error && focusClasses,
              error && errorClasses,
              disabled && disabledClasses,
              readOnly && readOnlyClasses,
              textareaClassName,
            )}
            style={{
              minHeight: rowsToHeight(minRows),
              maxHeight: maxRows ? rowsToHeight(maxRows) : undefined,
              ...style,
            }}
            {...props}
          />

          {showCount ? (
            <div
              id={countId}
              aria-live="polite"
              className={cn(
                "pointer-events-none absolute bottom-3 right-3 inline-flex items-center gap-1.5",
                "text-[length:var(--primitive-text-hint)] leading-none tabular-nums text-[var(--primitive-text-secondary)]",
                maxLength && currentLength > maxLength * 0.9 && "text-[var(--primitive-warning)]",
                maxLength && currentLength >= maxLength && "text-[var(--primitive-destructive)]",
              )}
            >
              <IconFileText
                aria-hidden="true"
                className="size-3.5 opacity-70"
                stroke={1.75}
              />
              <span>
                {currentLength}
                {maxLength ? `/${maxLength}` : null}
              </span>
            </div>
          ) : null}
        </div>

        {error ? (
          <p
            id={errorId}
            className="text-[12px] leading-[1.45] text-[var(--primitive-destructive)]"
          >
            {error}
          </p>
        ) : hint ? (
          <p
            id={hintId}
            className="text-[12px] leading-[1.45] text-[var(--primitive-text-secondary)]"
          >
            {hint}
          </p>
        ) : null}
      </div>
    );
  },
);

Textarea.displayName = "Textarea";
```
