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.
Use Cases
- Comment and feedback forms
- Bio or description fields on profile pages
- Message composition in chat or email interfaces
- Code or JSON input fields in developer tools
- Notes or memo fields in dashboards
- Support ticket descriptions
- Content editing areas in CMS interfaces
Simplest Implementation
"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.
Variations
With Label and Error
"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.
Auto-Resize
"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.
Character Count
"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.
With Helper Text
"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.
Disabled State
"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.
Controlled with Form Action
"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.
Complex Implementation
"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:
- forwardRef -- allows parent components to attach refs for focus management, form libraries, or imperative validation.
- useId for accessibility -- generates a stable unique ID to link
<label>,aria-describedby, and error/helper text without requiring the consumer to provide IDs. - Dual onChange API -- supports both the native
onChangeevent handler and a simplifiedonValueChangestring callback, making it compatible with form libraries and simple state alike. - Auto-resize with overflow hidden -- when
autoResizeis enabled, the textarea grows to fit content by resetting height toautothen setting it toscrollHeight, withresize-noneandoverflow-hiddento prevent visual artifacts. - Character count with threshold warning -- the counter turns red at 90% of
maxLength, giving users early warning before hitting the limit. - aria-describedby chaining -- links the textarea to both error and helper text elements so screen readers announce contextual information in the correct order.
- Disabled styling -- uses
disabled:cursor-not-allowedand a muted background so the disabled state is visually obvious without relying on opacity alone.
Gotchas
-
resize-y vs resize-none confusion -- Using
resize-yallows vertical resizing but conflicts with auto-resize behavior. When using programmatic height adjustment, always pair it withresize-noneto prevent the drag handle from fighting the script. -
scrollHeight requires auto height first -- Setting
el.style.height = el.scrollHeight + "px"without first resetting toautocauses the textarea to only grow, never shrink. Always reset toautobefore readingscrollHeight. -
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
rowsattribute only sets the initial height. After the first keystroke, programmatic height takes over. Set amin-heightin Tailwind if you need a guaranteed minimum. -
Form data serialization -- Textareas preserve newlines as
\r\nin form data on Windows. Normalize with.replace(/\r\n/g, "\n")if consistent line endings matter for your backend.
Related
- Input -- Single-line text input with shared styling conventions
- Button -- Submit buttons used alongside textareas in forms
- Forms -- Form patterns and validation strategies
- Event Handling -- onChange and event typing