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.
Casos de Uso
- Formularios de comentarios y retroalimentación
- Campos de biografía o descripción en páginas de perfil
- Composición de mensajes en interfaces de chat o correo electrónico
- Campos de entrada de código o JSON en herramientas para desarrolladores
- Campos de notas o memorandos en paneles de control
- Descripciones de tickets de soporte
- Áreas de edición de contenido en interfaces CMS
Implementación Más Simple
"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.
Variaciones
Con Etiqueta y 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>
);
}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.
Auto-Dimensionamiento
"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.
Contador de Caracteres
"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.
Con Texto de Ayuda
"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.
Estado Deshabilitado
"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.
Controlado con Acción de Formulario
"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.
Implementación Compleja
"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:
- forwardRef — permite que los componentes padres adjunten refs para gestión de enfoque, bibliotecas de formularios, o validación imperativa.
- useId para accesibilidad — genera un ID único estable para vincular
<label>,aria-describedby, y elementos de texto de error/ayuda sin requerir que el consumidor proporcione IDs. - API dual onChange — soporta tanto el manejador de evento
onChangenativo como un callbackonValueChangesimplificado de cadena, haciéndolo compatible con bibliotecas de formularios y estado simple. - Auto-dimensionamiento con overflow hidden — cuando
autoResizeestá habilitado, el textarea crece para ajustarse al contenido reiniciando la altura aautoy luego establenciéndola enscrollHeight, conresize-noneyoverflow-hiddenpara prevenir artefactos visuales. - Contador de caracteres con advertencia de umbral — el contador se vuelve rojo al 90% de
maxLength, dando a los usuarios advertencia temprana antes de alcanzar el límite. - Encadenamiento aria-describedby — vincula el textarea a elementos de texto de error y ayuda para que los lectores de pantalla anuncien información contextual en el orden correcto.
- Estilo deshabilitado — usa
disabled:cursor-not-allowedy un fondo atenuado para que el estado deshabilitado sea visualmente obvio sin depender solo de opacidad.
Trampas
-
Confusión resize-y vs resize-none — Usar
resize-ypermite cambio de tamaño vertical pero entra en conflicto con el comportamiento de auto-dimensionamiento. Cuando uses ajuste de altura programático, siempre empareja conresize-nonepara 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 aautocausa que el textarea solo crezca, nunca se encoja. Siempre reinicia aautoantes de leerscrollHeight. -
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
rowssolo establece la altura inicial. Después del primer pulsación de tecla, la altura programática toma el control. Establece unamin-heighten Tailwind si necesitas un mínimo garantizado. -
Serialización de datos de formulario — Los textareas preservan saltos de línea como
\r\nen datos de formulario en Windows. Normaliza con.replace(/\r\n/g, "\n")si líneas finales consistentes importan para tu backend.
Relacionado
- Input — Entrada de texto de una línea con convenciones de estilo compartidas
- Button — Botones de envío usados junto con textareas en formularios
- Forms — Patrones de formularios y estrategias de validación
- Event Handling — Tipado de onChange y eventos