Textarea
Una entrada de texto multi-línea para capturar contenido más extenso, como comentarios, descripciones o mensajes. Comparte convenciones de estilo con el componente Input para mantener consistencia visual en formularios.
Busca en todas las páginas de la documentación
Una entrada de texto multi-línea para capturar contenido más extenso, como comentarios, descripciones o mensajes. Comparte convenciones de estilo con el componente Input para mantener consistencia visual en formularios.
🤖 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>
);
}Un textarea etiquetado mínimo. El callback onChange devuelve el valor de cadena directamente para que el componente padre no necesite desempacar e.target.value. Envolver el textarea dentro de un elemento <label> asocia la etiqueta con el campo automáticamente.
"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>
);
}Alterna los colores de borde y anillo a rojo cuando hay un mensaje de error presente. El atributo aria-invalid le indica a los lectores de pantalla que el campo tiene un problema de validación.
"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>
);
}El textarea crece a medida que el usuario escribe reiniciando su altura a auto y luego establenciéndola en scrollHeight en cada cambio. La clase resize-none deshabilita la palanca de arrastre manual ya que la altura se gestiona programáticamente.
"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>
);
}Muestra un contador de caracteres en vivo debajo del textarea. El contador se vuelve rojo cuando quedan menos de 20 caracteres, dando al usuario una advertencia visual antes de alcanzar el límite. El atributo maxLength nativo impide exceder el máximo.
"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>
);
}El texto de ayuda aparece debajo del textarea cuando no hay error. Cuando hay un error presente, este toma prioridad, previniendo que mensajes conflictivos se apilen.
"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>
);
}La prop disabled impide la edición y aplica estilo atenuado a través de variantes disabled: de Tailwind. La etiqueta también se atenúa para reforzar el estado inactivo.
"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: "La retroalimentación debe tener al menos 10 caracteres." };
}
// Simula el envío al servidor
return { message: "¡Gracias por tu retroalimentación!" };
}
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">Tu Retroalimentación</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 ? "Enviando..." : "Enviar"}
</button>
{state.message && <p className="text-sm text-green-600">{state.message}</p>}
</form>
);
}Usa useActionState de React 19 para manejar el envío del formulario con validación del lado del servidor. El textarea no está controlado a través del atributo name, permitiendo que el navegador maneje la recopilación de FormData. La bandera isPending deshabilita el botón de envío durante el procesamiento asincrónico.
"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>
);
}
);Aspectos clave:
<label>, aria-describedby, y elementos de texto de error/ayuda sin requerir que el consumidor proporcione IDs.onChange nativo como un callback onValueChange simplificado de cadena, haciéndolo compatible con bibliotecas de formularios y estado simple.autoResize está habilitado, el textarea crece para ajustarse al contenido reiniciando la altura a auto y luego establenciéndola en scrollHeight, con resize-none y overflow-hidden para prevenir artefactos visuales.maxLength, dando a los usuarios advertencia temprana antes de alcanzar el límite.disabled:cursor-not-allowed y un fondo atenuado para que el estado deshabilitado sea visualmente obvio sin depender solo de opacidad.Confusión resize-y vs resize-none - Usar resize-y permite cambio de tamaño vertical pero entra en conflicto con el comportamiento de auto-dimensionamiento. Cuando uses ajuste de altura programático, siempre empareja con resize-none para prevenir que la palanca de arrastre interfiera con el script.
scrollHeight requiere altura auto primero - Establecer el.style.height = el.scrollHeight + "px" sin primero reiniciar a auto causa que el textarea solo crezca, nunca se encoja. Siempre reinicia a auto antes de leer scrollHeight.
Advertencia no controlado a controlado - Comenzar con value={undefined} y luego cambiar a una cadena dispara una advertencia de React. Siempre inicializa state como una cadena vacía cuando uses modo controlado.
maxLength no valida al pegar en todos los navegadores - Algunos navegadores antiguos permiten pegar más allá de maxLength. Siempre valida la longitud del lado del servidor o en tu manejador de envío como alternativa.
Prop rows ignorado con auto-dimensionamiento - Cuando el auto-dimensionamiento está activo, el atributo rows solo establece la altura inicial. Después del primer pulsación de tecla, la altura programática toma el control. Establece una min-height en Tailwind si necesitas un mínimo garantizado.
Serialización de datos de formulario - Los textareas preservan saltos de línea como \r\n en datos de formulario en Windows. Normaliza con .replace(/\r\n/g, "\n") si líneas finales consistentes importan para tu backend.
Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥