Textarea
A multi-line text input for capturing longer content such as comments, descriptions, or messages. Shares styling conventions with the Input component for visual consistency across forms.
Search across all documentation pages
A multi-line text input for capturing longer content such as comments, descriptions, or messages. Shares styling conventions with the Input component for visual consistency across forms.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
interface TextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
}
export function Textarea({ label, value, onChange }: TextareaProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={4}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</label>
);
}A minimal labeled textarea. The onChange callback returns the string value directly so the parent does not need to unwrap e.target.value. Wrapping the textarea inside a <label> element associates the label with the field automatically.
"use client";
interface TextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
error?: string;
}
export function Textarea({ label, value, onChange, error }: TextareaProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={4}
aria-invalid={!!error}
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</label>
);
}Toggles border and ring colors to red when an error message is present. The aria-invalid attribute tells screen readers the field has a validation problem.
"use client";
import { useRef, useCallback } from "react";
interface AutoResizeTextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
minRows?: number;
}
export function AutoResizeTextarea({
label,
value,
onChange,
minRows = 3,
}: AutoResizeTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
const el = e.target;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
},
[onChange]
);
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
rows={minRows}
className="mt-1 block w-full resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</label>
);
}The textarea grows as the user types by resetting its height to auto then setting it to scrollHeight on every change. The resize-none class disables the manual drag handle since height is managed programmatically.
"use client";
interface TextareaWithCountProps {
label: string;
value: string;
onChange: (value: string) => void;
maxLength: number;
}
export function TextareaWithCount({
label,
value,
onChange,
maxLength,
}: TextareaWithCountProps) {
const remaining = maxLength - value.length;
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
maxLength={maxLength}
rows={4}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<p
className={`mt-1 text-right text-xs ${
remaining < 20 ? "text-red-500" : "text-gray-400"
}`}
>
{value.length}/{maxLength}
</p>
</label>
);
}Displays a live character count below the textarea. The counter turns red when fewer than 20 characters remain, giving the user a visual warning before hitting the limit. The native maxLength attribute prevents exceeding the maximum.
"use client";
interface TextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
helperText?: string;
error?: string;
}
export function Textarea({ label, value, onChange, helperText, error }: TextareaProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={4}
aria-invalid={!!error}
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
{!error && helperText && <p className="mt-1 text-sm text-gray-500">{helperText}</p>}
</label>
);
}Helper text appears below the textarea when there is no error. When an error is present it takes priority, preventing conflicting messages from stacking.
"use client";
interface TextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
export function Textarea({ label, value, onChange, disabled = false }: TextareaProps) {
return (
<label className="block">
<span
className={`text-sm font-medium ${disabled ? "text-gray-400" : "text-gray-700"}`}
>
{label}
</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
rows={4}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500"
/>
</label>
);
}The disabled prop prevents editing and applies muted styling via Tailwind disabled: variants. The label also dims to reinforce the inactive state.
"use client";
import { useActionState } from "react";
interface FormState {
message: string;
error?: string;
}
async function submitFeedback(
_prev: FormState,
formData: FormData
): Promise<FormState> {
const content = formData.get("content") as string;
if (!content || content.trim().length < 10) {
return { message: "", error: "Feedback must be at least 10 characters." };
}
// Simulate server submission
return { message: "Thank you for your feedback!" };
}
export function FeedbackForm() {
const [state, formAction, isPending] = useActionState(submitFeedback, {
message: "",
});
return (
<form action={formAction} className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-gray-700">Your Feedback</span>
<textarea
name="content"
rows={5}
required
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 ${
state.error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
/>
{state.error && <p className="mt-1 text-sm text-red-600">{state.error}</p>}
</label>
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? "Submitting..." : "Submit"}
</button>
{state.message && <p className="text-sm text-green-600">{state.message}</p>}
</form>
);
}Uses React 19 useActionState to handle form submission with server-side validation. The textarea is uncontrolled via name attribute, letting the browser handle FormData collection. The isPending flag disables the submit button during async processing.
"use client";
import { forwardRef, useId, useRef, useCallback, useEffect } from "react";
type TextareaSize = "sm" | "md" | "lg";
interface TextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange"> {
label?: string;
helperText?: string;
error?: string;
size?: TextareaSize;
maxLength?: number;
showCount?: boolean;
autoResize?: boolean;
onValueChange?: (value: string) => void;
onChange?: React.ChangeEventHandler<HTMLTextAreaElement>;
fullWidth?: boolean;
}
const sizeClasses: Record<TextareaSize, { input: string; text: string }> = {
sm: { input: "px-2.5 py-1.5 text-xs", text: "text-xs" },
md: { input: "px-3 py-2 text-sm", text: "text-sm" },
lg: { input: "px-4 py-3 text-base", text: "text-base" },
};
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
function Textarea(
{
label,
helperText,
error,
size = "md",
maxLength,
showCount = false,
autoResize = false,
onValueChange,
onChange,
fullWidth = true,
disabled,
className,
id: externalId,
value,
defaultValue,
rows = 4,
...rest
},
ref
) {
const generatedId = useId();
const inputId = externalId ?? generatedId;
const errorId = `${inputId}-error`;
const helperId = `${inputId}-helper`;
const internalRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = (ref as React.RefObject<HTMLTextAreaElement>) ?? internalRef;
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el || !autoResize) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [autoResize, textareaRef]);
useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e);
onValueChange?.(e.target.value);
if (autoResize) {
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}
},
[onChange, onValueChange, autoResize]
);
const hasError = !!error;
const sizes = sizeClasses[size];
const currentLength =
typeof value === "string"
? value.length
: typeof defaultValue === "string"
? defaultValue.length
: 0;
return (
<div className={fullWidth ? "w-full" : "inline-flex flex-col"}>
{label && (
<label
htmlFor={inputId}
className={`mb-1 block font-medium text-gray-700 ${sizes.text}`}
>
{label}
</label>
)}
<textarea
ref={textareaRef}
id={inputId}
disabled={disabled}
value={value}
defaultValue={defaultValue}
onChange={handleChange}
rows={rows}
maxLength={maxLength}
aria-invalid={hasError}
aria-describedby={
[hasError ? errorId : null, helperText ? helperId : null]
.filter(Boolean)
.join(" ") || undefined
}
className={[
"block rounded-lg border shadow-sm transition-colors",
"focus:outline-none focus:ring-1",
"disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500",
"placeholder:text-gray-400",
autoResize ? "resize-none overflow-hidden" : "resize-y",
sizes.input,
fullWidth ? "w-full" : "",
hasError
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
className ?? "",
]
.filter(Boolean)
.join(" ")}
{...rest}
/>
<div className="mt-1 flex items-start justify-between gap-2">
<div>
{hasError && (
<p id={errorId} className={`text-red-600 ${sizes.text}`} role="alert">
{error}
</p>
)}
{!hasError && helperText && (
<p id={helperId} className={`text-gray-500 ${sizes.text}`}>
{helperText}
</p>
)}
</div>
{showCount && maxLength && (
<p
className={`shrink-0 ${sizes.text} ${
currentLength >= maxLength * 0.9 ? "text-red-500" : "text-gray-400"
}`}
>
{currentLength}/{maxLength}
</p>
)}
</div>
</div>
);
}
);Key aspects:
<label>, aria-describedby, and error/helper text without requiring the consumer to provide IDs.onChange event handler and a simplified onValueChange string callback, making it compatible with form libraries and simple state alike.autoResize is enabled, the textarea grows to fit content by resetting height to auto then setting it to scrollHeight, with resize-none and overflow-hidden to prevent visual artifacts.maxLength, giving users early warning before hitting the limit.disabled:cursor-not-allowed and a muted background so the disabled state is visually obvious without relying on opacity alone.resize-y vs resize-none confusion -- Using resize-y allows vertical resizing but conflicts with auto-resize behavior. When using programmatic height adjustment, always pair it with resize-none to prevent the drag handle from fighting the script.
scrollHeight requires auto height first -- Setting el.style.height = el.scrollHeight + "px" without first resetting to auto causes the textarea to only grow, never shrink. Always reset to auto before reading scrollHeight.
Uncontrolled to controlled warning -- Starting with value={undefined} then switching to a string triggers a React warning. Always initialize state as an empty string when using controlled mode.
maxLength does not validate on paste in all browsers -- Some older browsers allow pasting beyond maxLength. Always validate length server-side or in your submit handler as a fallback.
rows prop ignored with auto-resize -- When auto-resize is active, the rows attribute only sets the initial height. After the first keystroke, programmatic height takes over. Set a min-height in Tailwind if you need a guaranteed minimum.
Form data serialization -- Textareas preserve newlines as \r\n in form data on Windows. Normalize with .replace(/\r\n/g, "\n") if consistent line endings matter for your backend.
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥