docs / core / textarea

Textarea

text that runs past one line. same surface, focus ring and invalid state as Input, and it can grow with what you type.

intensity · recedesrsc · client

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

under a Label, inside a FieldGroup, with FieldCount for a limitautoGrow with a maxRows, so a long answer never eats the pagenot for one line. that is an Input, however wide you make itautoGrow without maxRows in a short form. it will run off screen

a11y

a native <textarea>, named by a Label or aria-label · aria-invalid paints the danger state · autoGrow only changes height, never focus or caretjest-axe
live demo · try it out
customize
placeholderhint text, never a label
valuethe starting text
autoGrowgrow with the content instead of scrolling
minRowsshortest it ever gets
3
maxRowswhere it stops growing and starts scrolling
10
invalidsets aria-invalid, paints danger
disableddims to 50%, not-allowed cursor
usagecopy
import { Textarea } from "usva/primitives/textarea";

<Textarea placeholder="Tell us about yourself…" rows={3} />

props

proptypedefaultnotes
autoGrowbooleanfalsegrows with the content instead of scrolling. off by default, so height stays yours.
minRowsnumber2shortest the field ever gets. also seeds the rows attribute under autoGrow.
maxRowsnumberwhere growing stops and scrolling starts. unbounded when unset.
aria-invalidbooleanfalsedanger border and ring for the field the error text points at.
disabledbooleanfalsedims to 50% with a not-allowed cursor.
…TextareaHTMLAttributesReact.TextareaHTMLAttributesevery native textarea attribute passes straight through.

get it

npx shadcn add https://usva.build/r/textarea.jsoncopy
source · components/ui/textarea.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";

const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

/** Computed styles come back empty for anything unset, and `normal` is not a number. */
const px = (value: string): number => Number.parseFloat(value) || 0;

/** React 18 types name this event FormEvent and React 19 types name it InputEvent. */
type InputHandler = NonNullable<
  React.TextareaHTMLAttributes<HTMLTextAreaElement>["onInput"]
>;

export interface TextareaProps
  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
  /** Grow with the content instead of scrolling, bounded by minRows and maxRows. */
  autoGrow?: boolean;
  /** Shortest the field ever gets. Also seeds the `rows` attribute under autoGrow. */
  minRows?: number;
  /** Tallest the field gets before it starts scrolling. Unbounded when unset. */
  maxRows?: number;
}

export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
  (
    { className, autoGrow = false, minRows = 2, maxRows, rows, ...props },
    ref,
  ) => {
    const inner = React.useRef<HTMLTextAreaElement | null>(null);

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

    const measure = React.useCallback(() => {
      const el = inner.current;
      if (!el || !autoGrow) return;

      const styles = window.getComputedStyle(el);
      const lineHeight =
        px(styles.lineHeight) || px(styles.fontSize) * 1.5 || 20;
      const borders = px(styles.borderTopWidth) + px(styles.borderBottomWidth);
      const chrome = px(styles.paddingTop) + px(styles.paddingBottom) + borders;

      const min = lineHeight * minRows + chrome;
      const max = maxRows
        ? lineHeight * maxRows + chrome
        : Number.POSITIVE_INFINITY;

      el.style.height = "auto";
      const natural = el.scrollHeight + borders;
      el.style.height = `${Math.min(Math.max(natural, min), max)}px`;
      el.style.overflowY = natural > max ? "auto" : "hidden";
    }, [autoGrow, minRows, maxRows]);

    useIsomorphicLayoutEffect(measure, [measure, props.value]);

    const handleInput: InputHandler = (event) => {
      props.onInput?.(event);
      measure();
    };

    return (
      <textarea
        ref={attach}
        rows={rows ?? (autoGrow ? minRows : undefined)}
        className={cn(
          "w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm text-ink placeholder:text-muted",
          "outline-none transition-control duration-base ease-soft",
          "hover:border-border-strong",
          "focus-visible:border-transparent focus-visible:ring-focus",
          "aria-invalid:border-danger aria-invalid:ring-2 aria-invalid:ring-danger/40",
          "disabled:cursor-not-allowed disabled:opacity-50",
          autoGrow ? "resize-none" : "resize-y",
          className,
        )}
        {...props}
        onInput={autoGrow ? handleInput : props.onInput}
      />
    );
  },
);
Textarea.displayName = "Textarea";