Skip to content
Seamless UI

Textarea

Save

A multi-line text field with a label, helper text, errors, an optional character counter, and optional auto-grow.

Category
Core
Version
0.1.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Textarea preview

Shown on your public profile.

0 of 160 characters used.

Describe at least one change.

Installation

npx @seamlessui/cli add textarea

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add textarea --dry-run

Usage

textarea-example.tsx
import { Textarea } from "@/components/seamless/ui/textarea";

export default function TextareaExample() {
  return (
    <form className="grid w-full max-w-sm gap-4">
      <Textarea
        label="Bio"
        placeholder="A sentence or two about you"
        description="Shown on your public profile."
        maxLength={160}
        autoGrow
      />
      <Textarea label="Release notes" defaultValue="Fixed" error="Describe at least one change." />
      <Textarea label="Internal notes" defaultValue="Locked while the review is open." disabled />
    </form>
  );
}

API

PropTypeDefaultDescription
labelReactNode—Visible label, linked to the textarea. Use aria-label when the label is shown elsewhere.
descriptionReactNode—Helper text below the field, linked with aria-describedby.
errorReactNode—Error message. Sets aria-invalid and replaces the description.
maxLengthnumber—Native character limit. Also turns on the character counter.
showCountbooleantrueShows the counter when maxLength is set. Set to false to keep the limit without a counter.
autoGrowbooleanfalseGrows with the content (CSS field-sizing: content) between 5rem and 16rem, then scrolls. Browsers without field-sizing fall back to rows.
rowsnumber3Initial height in lines when autoGrow is off.
containerClassNamestring—Classes for the wrapper. className styles the textarea itself.
...propsComponentProps<"textarea">—Any native textarea attribute, including value, defaultValue, onChange, and disabled.

Accessibility

  • The label is a native <label> bound to the textarea with an id generated by useId.
  • Helper text, errors, and the counter are linked with aria-describedby; errors also set aria-invalid.
  • The visible counter reads as "12 of 160 characters used" to screen readers when the field is focused.
  • A polite live region announces the characters left only near the limit and when it is reached, so typing is not interrupted on every keystroke.
  • At the limit the counter changes weight as well as color, and the numbers themselves show the limit.
  • Focus shows a border and ring; transitions are disabled with reduced motion.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/ui/textarea.tsxtextarea
  • npm packages: noneReact and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/textarea/0.1.0.json.

ui/textarea.tsxShow
ui/textarea.tsx
"use client";

import { type ChangeEvent, type ComponentProps, type ReactNode, useId, useState } from "react";

function cx(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(" ");
}

export interface TextareaProps extends ComponentProps<"textarea"> {
  /** Visible label. Pass `aria-label` instead when the label is shown elsewhere. */
  label?: ReactNode;
  /** Helper text below the field. */
  description?: ReactNode;
  /** Error message. Marks the field invalid and replaces the description. */
  error?: ReactNode;
  /** Grows with its content between a minimum and maximum height, then scrolls. */
  autoGrow?: boolean;
  /** Shows a character counter when `maxLength` is set. */
  showCount?: boolean;
  /** Classes for the wrapping element. `className` styles the textarea itself. */
  containerClassName?: string;
}

export function Textarea({
  label,
  description,
  error,
  autoGrow = false,
  showCount = true,
  maxLength,
  value,
  defaultValue,
  onChange,
  id,
  rows = 3,
  className,
  containerClassName,
  disabled,
  "aria-describedby": describedBy,
  ...props
}: TextareaProps) {
  const generatedId = useId();
  const textareaId = id ?? generatedId;
  const messageId = `${textareaId}-message`;
  const counterId = `${textareaId}-counter`;
  const invalid = error != null && error !== false;
  const message = invalid ? error : description;

  const [uncontrolledLength, setUncontrolledLength] = useState(() => String(defaultValue ?? "").length);
  const length = value !== undefined ? String(value ?? "").length : uncontrolledLength;
  const counting = showCount && maxLength !== undefined && maxLength > 0;
  const remaining = counting ? Math.max(maxLength - length, 0) : 0;
  // Announce only near the limit so screen readers are not interrupted on every keystroke.
  const warnAt = counting ? Math.max(10, Math.ceil(maxLength * 0.1)) : 0;
  const announcement = !counting
    ? ""
    : remaining === 0
      ? "Character limit reached."
      : remaining <= warnAt
        ? `${remaining} ${remaining === 1 ? "character" : "characters"} left.`
        : "";

  function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
    if (value === undefined) setUncontrolledLength(event.target.value.length);
    onChange?.(event);
  }

  return (
    <div className={cx("grid gap-1.5", containerClassName)}>
      {label ? (
        <label htmlFor={textareaId} className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
          {label}
        </label>
      ) : null}
      <textarea
        id={textareaId}
        rows={rows}
        disabled={disabled}
        maxLength={maxLength}
        value={value}
        defaultValue={defaultValue}
        onChange={handleChange}
        aria-invalid={invalid || undefined}
        aria-describedby={[message ? messageId : null, counting ? counterId : null, describedBy].filter(Boolean).join(" ") || undefined}
        className={cx(
          "block w-full rounded-lg border bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm placeholder:text-zinc-400",
          "transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none",
          "focus-visible:border-sky-500 focus-visible:ring-3 focus-visible:ring-sky-500/20 focus-visible:outline-none",
          "disabled:cursor-not-allowed disabled:bg-zinc-50 disabled:opacity-60",
          "dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:disabled:bg-zinc-900",
          autoGrow ? "field-sizing-content min-h-20 max-h-64 resize-none overflow-y-auto" : "resize-y",
          invalid
            ? "border-red-400 focus-visible:border-red-500 focus-visible:ring-red-500/20 dark:border-red-500/60"
            : "border-zinc-300 dark:border-zinc-700",
          className,
        )}
        {...props}
      />
      {message || counting ? (
        <div className="flex items-start justify-between gap-3">
          {message ? (
            <p id={messageId} className={invalid ? "text-sm text-red-600 dark:text-red-400" : "text-sm text-zinc-500 dark:text-zinc-400"}>
              {message}
            </p>
          ) : (
            <span />
          )}
          {counting ? (
            <p
              id={counterId}
              className={cx(
                "shrink-0 text-xs tabular-nums",
                remaining === 0 ? "font-medium text-red-600 dark:text-red-400" : "text-zinc-500 dark:text-zinc-400",
              )}
            >
              <span aria-hidden="true">
                {length}/{maxLength}
              </span>
              <span className="sr-only">
                {length} of {maxLength} characters used.
              </span>
            </p>
          ) : null}
        </div>
      ) : null}
      {counting ? (
        <span role="status" aria-live="polite" className="sr-only">
          {announcement}
        </span>
      ) : null}
    </div>
  );
}