Cómo crear un campo OTP de 6 dígitos en React
Un campo OTP en React divide un código en inputs controlados individuales, mueve el focus hacia delante al escribir un dígito y hacia atrás con Backspace, y valida cada pulsación contra un patrón numérico. Combínalo con una animación Framer Motion fade-up en la tarjeta para completar todo en menos de 150 líneas.
- Stack: React 18 + Framer Motion + Lucide React + Tailwind v4, ~87 líneas en total.
- La gestión del focus usa un array useRef, sin necesidad de ninguna librería OTP de terceros.
- Cada input relleno recibe un borde de acento de 2px; los inputs vacíos mantienen un borde neutro de 1px. El estado visual se controla únicamente con custom properties de CSS.
- inputMode='numeric' activa el teclado numérico en iOS y Android sin bloquear el pegado.
- La prop codeLength es configurable (por defecto 6), así que los códigos de 4 y 8 dígitos reutilizan el mismo componente sin modificarlo.
AuthVerifyEmail es una pantalla centrada de verificación de email construida en torno a un campo OTP multicasilla configurable. La tarjeta entra con un fade-up tipo spring, y luego las casillas gestionan toda la navegación con teclado de forma interna para que los desarrolladores la integren sin reescribir la lógica de focus desde cero. Cubre el paso de autenticación más habitual al que los equipos suelen prestar poca atención en el diseño.
Anatomía
La pantalla es una única motion.div centrada en un contenedor min-h-screen. De arriba abajo: una insignia circular con icono (Mail de Lucide, con color de acento), el título h1, una línea opcional de subtítulo y dirección de email, la fila de dígitos OTP, un botón de envío a todo el ancho y un enlace de reenvío. La fila de dígitos recorre un array de strings de longitud codeLength y renderiza un input de texto controlado por casilla.
Cómo funciona
El estado es un array de strings, una casilla por dígito, todas vacías por defecto. handleChange filtra la entrada no numérica con un test de regex, escribe el último carácter tecleado en el índice correspondiente y luego mueve el focus al input siguiente mediante el array de refs. handleKeyDown detecta Backspace en una casilla vacía y retrocede el focus un paso. La animación de entrada Framer Motion se ejecuta una vez al montar: opacity de 0 a 1, y de 20 a 0, durante 600ms con una curva ease spring personalizada.
Cómo crearlo en React
Inicializar el estado de los dígitos y las refs
Crea un array de strings vacíos de longitud codeLength y luego un array useRef paralelo para los inputs. Las refs te permiten llamar a .focus() de forma imperativa sin gestionar un índice de focus en el estado.
const [code, setCode] = useState<string[]>( Array.from({ length: codeLength }, () => "") ); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);Gestionar la entrada con avance automático
En cada evento change, rechaza todo lo que no sea un dígito, conserva solo el último carácter (gestiona el pegado de un único dígito), actualiza el array y luego pasa el focus a la casilla siguiente si existe.
function handleChange(index: number, value: string) { if (!/^d*$/.test(value)) return; const newCode = [...code]; newCode[index] = value.slice(-1); setCode(newCode); if (value && index < codeLength - 1) { inputRefs.current[index + 1]?.focus(); } }Retroceder con Backspace
Cuando la casilla actual ya está vacía y el usuario pulsa Backspace, mueve el focus a la casilla anterior. Así la corrección resulta natural sin necesidad de un seguimiento de estado adicional.
function handleKeyDown(index: number, e: React.KeyboardEvent) { if (e.key === "Backspace" && !code[index] && index > 0) { inputRefs.current[index - 1]?.focus(); } }Animar la entrada de la tarjeta con Framer Motion
Envuelve el contenedor en una motion.div con opacity 0 e y 20 como valores iniciales, anímala hacia opacity 1 e y 0, y pásale un array ease spring. La animación se ejecuta una vez al montar y no depende del estado de interacción.
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1]; <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} >
Cuándo usarlo
Usa este componente allí donde tu flujo de auth incluya verificación por email o teléfono: confirmación de registro, autenticación de dos factores o códigos de restablecimiento de contraseña. Encaja en el onboarding SaaS, las apps fintech y cualquier producto que necesite confirmar la propiedad de un dispositivo o una dirección. Evítalo si tu flujo usa un magic link en lugar de un código; el patrón multicasilla resultaría engañoso al no haber dígitos que introducir.
Usado por
- Stripe, Usa una fila de casillas OTP de 6 dígitos para la autenticación de dos factores en el inicio de sesión del Dashboard.
- GitHub, Presenta un campo OTP de casillas separadas durante la configuración del 2FA y la verificación de dispositivos.
- Linear, El paso de verificación por email usa un campo numérico segmentado, coherente con su design system minimalista.
- Notion, La confirmación de inicio de sesión por email envía un código de 6 dígitos que se introduce en una interfaz de inputs separados.
FAQ
¿Cómo gestiono el pegado de un código completo?
Añade un handler onPaste en el primer input que lea e.clipboardData.getData('text'), lo divida en caracteres individuales, filtre los no dígitos, rellene el array code y ponga el focus en la última casilla rellena. El componente actual gestiona el pegado dígito a dígito; el pegado de un código completo requiere un handler adicional.
¿Puedo cambiar el número de dígitos sin forkear el componente?
Pasa una prop codeLength distinta; el componente deriva tanto el array de estado como los inputs renderizados a partir de ese valor. Los códigos SMS de 4 dígitos y los códigos de respaldo de 8 dígitos funcionan sin ningún otro cambio.
¿Es inputMode='numeric' suficiente para los teclados móviles?
En la mayoría de los dispositivos sí: muestra el teclado numérico en iOS y Android sin restringir el pegado ni las APIs del portapapeles. Añadir además pattern='[0-9]*' cubre los WebViews antiguos de Android que ignoran inputMode.
¿Cómo envío automáticamente cuando se introduce el último dígito?
Dentro de handleChange, tras actualizar el array code, comprueba si el índice actualizado vale codeLength - 1 y el nuevo valor no está vacío; si es así, llama a tu función de envío con la cadena code unida. Un useEffect que vigile el array code en busca de un estado completo es igual de válido.