UI Patterns
Input

Textarea

Displays a multi-line text input — patterns for autoresize and a live character counter.

Examples

Autoresize

Grows as the user types, up to a sensible maximum, without showing a scrollbar until that cap is hit. Uses the CSS field-sizing: content property so no JavaScript is required.

Uses CSS field-sizing: content to grow with the value.

components/examples/textarea-autoresize.tsx
"use client";

import * as React from "react";

import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";

export function TextareaAutoresizeDemo() {
  const id = React.useId();
  const [value, setValue] = React.useState(
    "Hey, this textarea grows as you type. Try adding more lines!",
  );

  return (
    <div className="grid w-full gap-2">
      <Label htmlFor={id}>Autoresize textarea</Label>
      <Textarea
        id={id}
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="Start typing to see me grow..."
        rows={2}
        className="field-sizing-content max-h-48 min-h-16 resize-none"
      />
    </div>
  );
}

Notes:

  • rows={2} sets the initial collapsed height.
  • max-h-48 caps growth; after that the browser shows a scrollbar.
  • resize-none hides the manual resize handle, since it is no longer needed.
  • field-sizing: content is supported in Chromium and Firefox. For Safari, measure scrollHeight in a useLayoutEffect and set height directly.

Character limit

Shows a live counter against a hard maximum. The counter updates on every keystroke, shifts visual weight near the limit, and is announced to assistive tech via aria-live="polite" linked by aria-describedby.

0 of 180 characters used.
components/examples/textarea-character-limit.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";

const MAX_LENGTH = 180;

export function TextareaCharacterLimitDemo() {
  const id = React.useId();
  const counterId = `${id}-counter`;
  const [value, setValue] = React.useState("");

  const remaining = MAX_LENGTH - value.length;
  const isAtLimit = remaining <= 0;

  return (
    <div className="grid w-full gap-2">
      <Label htmlFor={id}>Message</Label>
      <Textarea
        id={id}
        value={value}
        onChange={(e) => setValue(e.target.value.slice(0, MAX_LENGTH))}
        placeholder="Write a short message..."
        maxLength={MAX_LENGTH}
        aria-describedby={counterId}
        className="field-sizing-content min-h-20 resize-none"
      />
      <div
        id={counterId}
        aria-live="polite"
        className={cn(
          "text-muted-foreground text-right text-xs tabular-nums",
          remaining <= 20 && "text-foreground",
          isAtLimit && "text-destructive",
        )}
      >
        <span className="sr-only">
          {value.length} of {MAX_LENGTH} characters used.{" "}
        </span>
        <span aria-hidden="true">
          {value.length}/{MAX_LENGTH}
        </span>
      </div>
    </div>
  );
}

Notes:

  • Use the native maxLength attribute and slice in onChangemaxLength does not prevent every programmatic paste / IME path.
  • Show used/max (42/180), not just "remaining". Users scan for the ceiling as much as the current value.
  • Switch color near the limit (last ~10%) and use the destructive color once the limit is reached.
  • Put the long-form phrase in a visually-hidden span for screen readers and the short form for sighted users.

On this page