An auto-growing textarea using field-sizing: content with a JS fallback, a limit-aware character count, and the house error shake with fading validation messages.
npx shadcn@latest add @paragon/textarea"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.ComponentProps<"textarea"> {
/** Visible label, wired via htmlFor. */
label?: string;
/** Helper text below the field. Hidden while an error is shown. */
hint?: string;
/** Error message. Turns the field red, fades the message in, shakes once. */
error?: string;
/** Grow with content. Uses `field-sizing: content` with a JS fallback. */
autoGrow?: boolean;
/** Show the character count. Defaults to true when maxLength is set. */
showCount?: boolean;
/**
* Change this value to replay the shake even when the error message is
* unchanged (e.g. increment on every failed submit).
*/
shakeKey?: string | number;
}
/**
* Auto-growing textarea. Prefers the native `field-sizing: content` and
* falls back to a scrollHeight measure where unsupported. The character
* count is tabular-nums and tightens color as the limit approaches; errors
* shake once and fade their message in, matching the Input recipe.
*/
export function Textarea({
label,
hint,
error,
autoGrow = true,
showCount,
shakeKey,
maxLength,
className,
id: idProp,
rows = 3,
onChange,
disabled,
"aria-describedby": ariaDescribedBy,
...props
}: TextareaProps) {
const autoId = React.useId();
const id = idProp ?? autoId;
const messageId = `${id}-message`;
const ref = React.useRef<HTMLTextAreaElement>(null);
const initial =
props.value ?? props.defaultValue ?? "";
const [count, setCount] = React.useState(String(initial).length);
const length =
props.value !== undefined ? String(props.value).length : count;
const [nativeSizing, setNativeSizing] = React.useState(true);
React.useEffect(() => {
setNativeSizing(
typeof CSS !== "undefined" && CSS.supports("field-sizing", "content"),
);
}, []);
const resize = React.useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight + 2}px`; // +2 for the border
}, []);
// JS fallback: measure on mount and whenever the (controlled) value changes.
React.useLayoutEffect(() => {
if (autoGrow && !nativeSizing) resize();
}, [autoGrow, nativeSizing, resize, props.value]);
const [shaking, setShaking] = React.useState(false);
React.useEffect(() => {
if (error) setShaking(true);
}, [error, shakeKey]);
const showsCount = (showCount ?? maxLength !== undefined) && !disabled;
const ratio = maxLength ? length / maxLength : 0;
const invalid = Boolean(error);
const message = error ?? hint;
return (
<div className="w-full">
<style href="paragon-textarea" precedence="paragon">{`
@keyframes paragon-textarea-shake {
0% { translate: 0; animation-timing-function: cubic-bezier(0.36, 0, 0.66, 0.2); }
25% { translate: -6px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
50% { translate: 5px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
75% { translate: -3px 0; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
100% { translate: 0; }
}
@keyframes paragon-textarea-message-in {
from { opacity: 0; translate: 0 -2px; filter: blur(2px); }
}
@media (prefers-reduced-motion: reduce) {
.paragon-textarea-shake { animation: none !important; }
.paragon-textarea-message { animation: none !important; }
}
`}</style>
{label && (
<label
htmlFor={id}
className="mb-1.5 block text-sm font-medium text-foreground"
>
{label}
</label>
)}
<div
className={cn(shaking && "paragon-textarea-shake")}
style={
shaking
? { animation: "paragon-textarea-shake 280ms both" }
: undefined
}
onAnimationEnd={(event) => {
if (event.animationName === "paragon-textarea-shake") {
setShaking(false);
}
}}
>
<textarea
ref={ref}
id={id}
rows={rows}
maxLength={maxLength}
disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={
cn(message ? messageId : undefined, ariaDescribedBy) || undefined
}
onChange={(event) => {
if (props.value === undefined) setCount(event.target.value.length);
if (autoGrow && !nativeSizing) resize();
onChange?.(event);
}}
className={cn(
"block w-full min-w-0 resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm text-foreground",
"transition-[border-color,box-shadow,background-color] duration-150 ease-out",
"placeholder:text-muted-foreground",
"outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
"read-only:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50",
invalid &&
"border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
!invalid && "hover:not-disabled:not-focus-visible:border-ring/60",
className,
)}
style={
autoGrow
? ({ fieldSizing: "content" } as React.CSSProperties)
: undefined
}
{...props}
/>
</div>
{(message || showsCount) && (
<div className="mt-1.5 flex items-baseline justify-between gap-4">
<p
id={messageId}
role={invalid ? "alert" : undefined}
// Keyed so a new message re-runs the fade-in; the shake is a
// separate class on the field wrapper, so replaying it never
// flickers this element.
key={message ?? "hint"}
className={cn(
"paragon-textarea-message min-w-0 text-[13px]",
invalid ? "text-destructive" : "text-muted-foreground",
)}
style={
message
? {
animation:
"paragon-textarea-message-in 200ms var(--ease-out) both",
}
: undefined
}
>
{message}
</p>
{showsCount && (
<span
aria-hidden
className={cn(
"shrink-0 text-xs tabular-nums transition-colors duration-150 ease-out",
ratio >= 1
? "text-destructive"
: ratio >= 0.85
? "text-warning"
: "text-muted-foreground/70",
)}
>
{maxLength !== undefined ? `${length}/${maxLength}` : length}
</span>
)}
</div>
)}
</div>
);
}