← ui

textarea

ui

text of several lines that grows as it is written, plain or glass

npx shadcn add @ag/textarea
Already installed? Preview an update
npx shadcn add @ag/textarea --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-10
  • New: text of more than a line in the input's well (red when aria-invalid, the input's three type sizes), growing with what is written from rows to maxRows, then scrolling; the browser sizes it where it can (field-sizing: content) and it is measured where it cannot. autosize={false} keeps it rows tall. Plain, or a well of glass.
Updated 2026-10-10 · release notes

also called autosize

shelf controls

uses material

used by components, thumbs

the demo above, verbatim

textarea.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Textarea } from "@/registry/base-nova/ui/textarea"

// #region note
function Note() {
  const [text, setText] = React.useState("")
  return (
    <Textarea
      value={text}
      onChange={(e) => setText(e.target.value)}
      placeholder="write a few lines: it grows to six, then scrolls"
      rows={2}
      maxRows={6}
      aria-invalid={text.length > 280 || undefined}
    />
  )
}
// #endregion

export default function TextareaDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="grows as you write"
        description="red past 280 characters (aria-invalid)."
        with="note"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <Note />
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
              <Note />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="sizes" glance title="three sizes, fixed rows">
        <div className="grid w-full max-w-80 gap-3">
          <Textarea size="sm" autosize={false} rows={2} placeholder="small" />
          <Textarea autosize={false} rows={2} placeholder="medium" />
          <Textarea size="lg" autosize={false} rows={2} placeholder="large" />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

textarea.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"

// Text of more than a line, in the input's well: the same sunken fill and inset ring,
// brightening under the hand and clear on focus, red when the value is wrong
// (`aria-invalid`), at the input's three type sizes. It grows with what is written,
// from `rows` lines up to `maxRows` (then it scrolls); the browser sizes it where it
// can (`field-sizing: content`), and where it cannot, it is measured as it is typed
// in. `autosize={false}` keeps it `rows` tall, the reader dragging nothing (no resize
// handle either way: the field's size is its content's).
// `material="glass"`: a well of glass, its rim brightening on focus, red when wrong.

export type TextareaSize = "sm" | "md" | "lg"

const TYPE: Record<TextareaSize, string> = {
  sm: "px-2 py-1 text-[12px] leading-[18px]",
  md: "px-2.5 py-1.5 text-[13px] leading-5",
  lg: "px-3 py-2 text-sm leading-6",
}
/** Each size's padding above and below, px: what a line count is grown by. */
const PAD_Y: Record<TextareaSize, number> = { sm: 4, md: 6, lg: 8 }

/** The input's well, on the text itself. */
const WELL =
  "rounded-md bg-foreground/[0.04] ring-1 ring-foreground/12 ring-inset transition-[box-shadow,background-color] placeholder:text-foreground/35 hover:ring-foreground/22 focus-visible:bg-foreground/[0.06] focus-visible:ring-foreground/40 aria-invalid:ring-tone-bad/70"

const GLASS_WELL =
  "[--glass-radius:--theme(--radius-xl)] placeholder:text-current/40 hover:[--glass-rim:1.3]! focus-visible:[--glass-rim:1.7]! aria-invalid:[--glass-stain:var(--tone-bad)]! aria-invalid:[--glass-rim-color:var(--tone-bad)]! aria-invalid:[--glass-rim:2.2]!"

/** Whether this browser sizes a field to its content itself. */
const SIZES_ITSELF =
  typeof CSS !== "undefined" && CSS.supports("field-sizing", "content")

export type TextareaProps = Omit<React.ComponentProps<"textarea">, "rows"> & {
  size?: TextareaSize
  /** Lines tall at least (and exactly, without autosize). */
  rows?: number
  /** Lines tall at most while it grows; then it scrolls. Unset, it grows on. */
  maxRows?: number
  /** Grow with what is written. Default on. */
  autosize?: boolean
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
}

export function Textarea({
  size = "md",
  rows = 3,
  maxRows,
  autosize = true,
  material,
  scheme,
  className,
  style,
  ref,
  onInput,
  ...props
}: TextareaProps) {
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the field.
  const [s, here] = useSurfaceScheme<HTMLTextAreaElement>(scheme, ref)
  const own = React.useRef<HTMLTextAreaElement | null>(null)
  const attach = React.useCallback(
    (el: HTMLTextAreaElement | null) => {
      own.current = el
      here(el)
    },
    [here],
  )
  // Where the browser cannot size it, its height is measured from what it holds.
  const fit = React.useCallback(() => {
    const el = own.current
    if (!el || !autosize || SIZES_ITSELF) return
    el.style.height = "auto"
    el.style.height = `${el.scrollHeight}px`
  }, [autosize])
  // biome-ignore lint/correctness/useExhaustiveDependencies: a value set from outside is measured too
  React.useLayoutEffect(fit, [fit, props.value])
  const lines = (n: number) => `calc(${n}lh + ${2 * PAD_Y[size]}px)`
  return (
    <textarea
      ref={attach}
      data-slot="textarea"
      data-size={size}
      data-material={m}
      rows={autosize ? undefined : rows}
      onInput={(e) => {
        fit()
        onInput?.(e)
      }}
      style={
        autosize
          ? ({
              "--ag-textarea-min": lines(rows),
              "--ag-textarea-max": maxRows ? lines(maxRows) : "none",
              ...style,
            } as React.CSSProperties)
          : style
      }
      className={cn(
        "block w-full resize-none outline-none disabled:cursor-not-allowed disabled:opacity-50",
        TYPE[size],
        autosize &&
          "min-h-(--ag-textarea-min) max-h-(--ag-textarea-max) [field-sizing:content]",
        schemeOf(s),
        m === "glass"
          ? [glassVariants({ shape: "surface" }), GLASS_WELL]
          : WELL,
        className,
      )}
      {...props}
    />
  )
}