Entrada
Un campo de formulario para capturar entrada de texto del usuario, con soporte para etiquetas, estados de validación y varios tipos de entrada.
Casos de Uso
- Recopilar información del usuario en formularios de registro y perfil
- Barras de búsqueda con icono y botón de borrado
- Formularios de inicio de sesión con campos de correo electrónico y contraseña
- Edición en línea de celdas de tabla o elementos de lista
- Campos de composición de mensajes de chat
- Entradas de filtro y autocompletar en paneles de control
- Formularios de comentarios o retroalimentación de múltiples líneas usando textarea
Implementación Más Simple
"use client";
interface InputProps {
label: string;
value: string;
onChange: (value: string) => void;
}
export function Input({ label, value, onChange }: InputProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
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>
);
}Una entrada etiquetada mínima. La devolución de llamada onChange devuelve el valor de cadena directamente para que el componente padre no necesite desenvuelto e.target.value. Envolver la entrada dentro de un elemento <label> asocia la etiqueta con el campo sin necesidad de atributos htmlFor e id.
Variaciones
Con Estado de Error
"use client";
interface InputProps {
label: string;
value: string;
onChange: (value: string) => void;
error?: string;
}
export function Input({ label, value, onChange, error }: InputProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
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.
Con Texto de Ayuda
"use client";
interface InputProps {
label: string;
value: string;
onChange: (value: string) => void;
helperText?: string;
error?: string;
}
export function Input({ label, value, onChange, helperText, error }: InputProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
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 de la entrada cuando no hay error. Cuando hay un error presente, toma prioridad, evitando que los mensajes conflictivos se apilen.
Alternancia de Contraseña
"use client";
import { useState } from "react";
interface PasswordInputProps {
label: string;
value: string;
onChange: (value: string) => void;
}
export function PasswordInput({ label, value, onChange }: PasswordInputProps) {
const [visible, setVisible] = useState(false);
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<div className="relative mt-1">
<input
type={visible ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
className="block w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:text-gray-600"
aria-label={visible ? "Ocultar contraseña" : "Mostrar contraseña"}
>
{visible ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-5 0-9.27-3.11-11-7.5a11.72 11.72 0 013.168-4.477M6.343 6.343A9.97 9.97 0 0112 5c5 0 9.27 3.11 11 7.5a11.7 11.7 0 01-4.373 5.157M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3l18 18" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-.274.857-.642 1.68-1.1 2.453M12 19c-1.39 0-2.72-.285-3.927-.8" />
</svg>
)}
</button>
</div>
</label>
);
}Alterna entre type="text" y type="password" con un botón de visibilidad. El botón de alternancia usa type="button" para evitar el envío del formulario y aria-label para comunicar el estado actual.
Entrada de Búsqueda con Icono
"use client";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function SearchInput({ value, onChange, placeholder = "Buscar..." }: SearchInputProps) {
return (
<div className="relative">
<svg
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="block w-full rounded-lg border border-gray-300 py-2 pl-10 pr-3 text-sm shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
);
}El icono de búsqueda se posiciona absolutamente dentro del contenedor de entrada. El relleno izquierdo (pl-10) evita que el texto se superponga al icono. Usar type="search" da a los navegadores botones de limpieza nativos en algunas plataformas.
Variante Textarea
"use client";
interface TextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
maxLength?: number;
}
export function Textarea({ label, value, onChange, rows = 4, maxLength }: 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={rows}
maxLength={maxLength}
className="mt-1 block w-full resize-y 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"
/>
{maxLength && (
<p className="mt-1 text-right text-xs text-gray-400">
{value.length}/{maxLength}
</p>
)}
</label>
);
}Una entrada de múltiples líneas con contador de caracteres opcional. La clase resize-y permite cambiar el tamaño verticalmente solo, evitando problemas de desbordamiento horizontal. El contador se actualiza en vivo mientras el usuario escribe.
Implementación Compleja
"use client";
import { forwardRef, useId, useState, useCallback } from "react";
type InputSize = "sm" | "md" | "lg";
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "onChange"> {
label?: string;
helperText?: string;
error?: string;
size?: InputSize;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
onValueChange?: (value: string) => void;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
fullWidth?: boolean;
}
const sizeClasses: Record<InputSize, { input: string; text: string }> = {
sm: { input: "h-8 px-2.5 text-xs", text: "text-xs" },
md: { input: "h-10 px-3 text-sm", text: "text-sm" },
lg: { input: "h-12 px-4 text-base", text: "text-base" },
};
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
label,
helperText,
error,
size = "md",
leftIcon,
rightIcon,
onValueChange,
onChange,
fullWidth = true,
disabled,
className,
id: externalId,
...rest
},
ref
) {
const generatedId = useId();
const inputId = externalId ?? generatedId;
const errorId = `${inputId}-error`;
const helperId = `${inputId}-helper`;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e);
onValueChange?.(e.target.value);
},
[onChange, onValueChange]
);
const hasError = !!error;
const sizes = sizeClasses[size];
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>
)}
<div className="relative">
{leftIcon && (
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{leftIcon}
</span>
)}
<input
ref={ref}
id={inputId}
disabled={disabled}
onChange={handleChange}
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",
sizes.input,
fullWidth ? "w-full" : "",
leftIcon ? "pl-10" : "",
rightIcon ? "pr-10" : "",
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}
/>
{rightIcon && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
{rightIcon}
</span>
)}
</div>
{hasError && (
<p id={errorId} className={`mt-1 text-red-600 ${sizes.text}`} role="alert">
{error}
</p>
)}
{!hasError && helperText && (
<p id={helperId} className={`mt-1 text-gray-500 ${sizes.text}`}>
{helperText}
</p>
)}
</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 y 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 eventos
onChangenativo como una devolución de llamada de cadena simplificadaonValueChange, haciéndolo compatible con bibliotecas de formularios y estado simple. - encadenamiento aria-describedby -- vincula la entrada tanto a elementos de error como de texto de ayuda para que los lectores de pantalla anuncien información contextual en el orden correcto.
- Ranuras de icono con pointer-events-none -- el icono izquierdo es puramente decorativo y pasa clics a través a la entrada. La ranura de icono derecho permite elementos interactivos como botones de borrado.
- Estilos deshabilitados -- usa
disabled:cursor-not-allowedy un fondo atenuado para que el estado deshabilitado sea visualmente obvio sin depender solo de la opacidad.
Gotchas
-
Advertencia de componente no controlado a controlado -- Comenzar con
value={undefined}y luego cambiar a una cadena activa una advertencia de React. Siempre inicializa el estado como una cadena vacía, noundefined. -
Falta de asociación
id/htmlFor-- Usar un<label>que no está envolviendo la entrada y no está conectado víahtmlForsignifica que hacer clic en la etiqueta no enfoca la entrada. Envuelve la entrada en la etiqueta o usa atributosidyhtmlForcoincidentes. -
onChange devuelve evento, no valor -- A diferencia de algunas bibliotecas de componentes, el
onChangenativo te da un objeto de evento. Olvidare.target.valuees una fuente común de[object Object]que aparece en entradas. -
Peculiaridades de type="number" --
onChangetodavía devuelve una cadena contype="number". UsaparseFloat(e.target.value)y manejaNaN. Además, las entradas numéricas permiten caracterese,+,-, y.queparseIntignora silenciosamente. -
Desajuste del teclado móvil -- Usar
type="text"para campos de correo electrónico o teléfono muestra un teclado genérico en dispositivos móviles. Usatype="email",type="tel", oinputMode="numeric"para obtener el diseño de teclado correcto. -
Conflictos de estilos de relleno automático -- El relleno automático del navegador aplica su propio color de fondo (generalmente amarillo o azul). Anula con
autofill:bg-white autofill:shadow-[inset_0_0_0px_1000px_white]en Tailwind si es necesario.
Relacionado
- Button -- Botones de envío utilizados junto a entradas
- Formularios -- Patrones de formularios y estrategias de validación
- Manejo de Eventos -- Tipado de onChange y eventos