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 usvalayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
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 screena11y
a native<textarea>, named by a Label or aria-label · aria-invalid paints the danger state · autoGrow only changes height, never focus or caretjest-axelive demo · try it out
customize
placeholderhint text, never a label
valuethe starting text
autoGrowgrow with the content instead of scrolling
minRowsshortest it ever gets
maxRowswhere it stops growing and starts scrolling
invalidsets aria-invalid, paints danger
disableddims to 50%, not-allowed cursor
usage
import { Textarea } from "usva/primitives/textarea";
<Textarea placeholder="Tell us about yourself…" rows={3} />props
| prop | type | default | notes |
|---|---|---|---|
| autoGrow | boolean | false | grows with the content instead of scrolling. off by default, so height stays yours. |
| minRows | number | 2 | shortest the field ever gets. also seeds the rows attribute under autoGrow. |
| maxRows | number | — | where growing stops and scrolling starts. unbounded when unset. |
| aria-invalid | boolean | false | danger border and ring for the field the error text points at. |
| disabled | boolean | false | dims to 50% with a not-allowed cursor. |
| …TextareaHTMLAttributes | React.TextareaHTMLAttributes | — | every native textarea attribute passes straight through. |
get it
npx shadcn add https://usva.build/r/textarea.jsonsource · components/ui/textarea.tsxexactly what this command copies
"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";