text of several lines that grows as it is written, plain or glass
npx shadcn add @ag/textareaAlready installed? Preview an update
npx shadcn add @ag/textarea --dry-runYour 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 fromrowstomaxRows, then scrolling; the browser sizes it where it can (field-sizing: content) and it is measured where it cannot.autosize={false}keeps itrowstall. Plain, or a well of glass.
the demo above, verbatim
"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
"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}
/>
)
}