Cómo crear una entrada OTP 2FA en React con auto-focus y soporte de pegado
Un componente OTP 2FA en React muestra N campos de un solo dígito, avanza el focus automáticamente con cada pulsación, retrocede al pulsar Backspace y gestiona el pegado desde el portapapeles distribuyendo los dígitos. Framer Motion anima la entrada de la tarjeta, el stagger de los campos y el cambio a la pantalla de éxito mediante AnimatePresence.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~310 líneas, sin librerías adicionales.
- APIs principales de React: useState, useRef, useCallback, useEffect, useInView.
- Pegado desde el portapapeles: elimina los caracteres no numéricos, rellena los campos de izquierda a derecha y enfoca el siguiente slot vacío.
- La cuenta atrás de reenvío usa una cadena de setTimeout de un segundo que se limpia al desmontar.
- Accesible: inputMode='numeric' activa el teclado numérico en móvil; cada campo se puede enfocar individualmente.
Esta sección de auth ofrece una pantalla de verificación de dos factores completa: seis campos OTP individuales, progresión automática del focus, navegación total con el teclado, pegado desde el portapapeles, cuenta atrás de reenvío y un estado de éxito animado. Cuida las microinteracciones que los usuarios notan: el borde que se ilumina al escribir, la entrada fluida del mensaje de error, el icono de candado que crece al validar.
Anatomía
La maquetación es una única columna centrada con un ancho máximo de 400px. Arriba se sitúa un icono ShieldCheck en una caja redondeada de color de acento, seguido del título y el subtítulo. Debajo, los seis inputs OTP forman una fila flex con una separación de 8px; cada input mide 48px de ancho y 56px de alto. Un botón de validación ocupa todo el ancho y pasa de un estado deshabilitado atenuado a un fondo de acento cuando todos los campos están completos. La fila de reenvío y el enlace de retorno quedan debajo. Al tener éxito, AnimatePresence sustituye el formulario por una tarjeta con un icono Lock que crece con la animación.
Cómo funciona
Cada campo contiene exactamente un carácter; el handler onChange elimina los caracteres no numéricos, escribe el valor en un array de strings y luego llama a focus sobre inputRefs[index + 1] cuando el campo está completo. El handler de Backspace comprueba si el campo actual está vacío, y si lo está, mueve el focus a inputRefs[index - 1], permitiendo una edición hacia atrás fluida. El pegado solo se gestiona en el primer campo: el texto del portapapeles se limpia dejando únicamente dígitos, se distribuye en el array y luego el focus aterriza en el siguiente slot vacío. La cuenta atrás pasa por un useEffect que programa un timeout de un segundo y decrementa el estado; el efecto se limpia entre cada tick para evitar acumulaciones. Framer Motion aporta dos capas de animación: una entrada de la tarjeta (opacity 0→1, y 30→0 con ease spring) y un stagger por dígito (retraso = index × 0,05s) para que los campos aparezcan en cascada. AnimatePresence en modo 'wait' gestiona la transición de formulario a éxito sin desplazamiento de la maquetación.
Cómo crearlo en React
Crear el estado del array de dígitos y las refs
Inicializa un array de strings con Array(codeLength).fill('') y un array inputRefs paralelo de la misma longitud. El array de refs permite llamar a focus() de forma imperativa sobre cualquier campo por su índice, algo que el estado de React por sí solo no puede hacer.
const [code, setCode] = useState<string[]>(Array(codeLength).fill("")); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);Conectar onChange, Backspace y el pegado
En onChange, rechaza los caracteres no numéricos con una guarda regex, escribe el último carácter en el slot correcto y luego avanza el focus. En onKeyDown, cuando Backspace se dispara sobre un campo vacío, retrocede el focus un paso. Asocia el handler de pegado únicamente al primer input, parsea los dígitos, rellena el array y luego enfoca el siguiente índice vacío.
const handleChange = (index: number, value: string) => { if (!/^\d*$/.test(value)) return; const next = [...code]; next[index] = value.slice(-1); setCode(next); if (value && index < codeLength - 1) inputRefs.current[index + 1]?.focus(); };Animar los campos con un stagger por dígito
Sustituye el simple <input> por <motion.input> y añade un par initial/animate que lee del hook useInView. Pon el delay en i * 0,05 para que cada campo entre en cascada 50ms después del anterior. Así la entrada resulta animada sin abrumar al usuario.
<motion.input initial={{ opacity: 0, y: 10 }} animate={inView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.3, delay: i * 0.05, ease: EASE }} />Cambiar al estado de éxito con AnimatePresence
Envuelve tanto el formulario como la tarjeta de éxito en <AnimatePresence mode='wait'>. Dale a cada uno una key única para que Framer Motion saque la vista anterior antes de montar la nueva. La tarjeta de éxito usa una animación scale anidada sobre el icono Lock (scale 0→1) para reforzar la metáfora de verificación.
Cuándo usarlo
Usa este componente como paso 2FA dedicado en un flujo de autenticación de varias pantallas, justo después del inicio de sesión por contraseña cuando el usuario tiene SMS o TOTP configurado. Funciona para dashboards SaaS, paneles de administración, banca y cualquier producto donde se requiera un segundo factor. Evítalo cuando el 2FA es opcional o aún no está implementado; mostrar esta pantalla a usuarios que no se han inscrito generará confusión. En móvil el teclado numérico se activa automáticamente mediante inputMode='numeric', pero prueba el comportamiento del pegado en Safari, ya que el acceso al portapapeles tiene peculiaridades conocidas.
Usado por
- GitHub, Usa una disposición en cajas OTP para el 2FA por SMS y app de autenticación, con avance automático del focus en cada dígito.
- Stripe, Los códigos de verificación durante el inicio de sesión usan campos individuales con el mismo mecanismo de pegado y distribución.
- Linear, Pantalla 2FA centrada y minimalista con inputs por dígito y enlace de reenvío, coherente con su UX sobria orientada al teclado.
- Vercel, La entrada OTP durante el inicio de sesión de equipo usa cajas individuales con avance automático del focus y soporte de la tecla Backspace.
FAQ
¿Cómo funciona el handler de pegado en los seis campos?
El listener de pegado se asocia únicamente al primer input. Al dispararse, llama a e.preventDefault() para evitar que el navegador inserte el texto en bruto, extrae solo los caracteres numéricos de la cadena del portapapeles, la recorta a codeLength y luego mapea cada dígito en el array code. El focus se desplaza después al primer slot vacío, o al último campo si todos están llenos.
¿Por qué usar inputs individuales en lugar de un único campo de texto?
Los inputs separados ofrecen una progresión visual (cada caja se rellena al escribir), una navegación de teclado natural mediante Backspace y control sobre lo que muestra el teclado virtual en móvil. Un único input oculto es una alternativa, pero requiere una capa de renderizado custom, añadiendo complejidad sin un beneficio real de UX para un código de longitud fija.
¿Puedo cambiar la longitud del código de 6 a 4 u 8 dígitos?
Pasa una prop codeLength diferente. El estado del array, el bucle de renderizado de los inputs y la validación derivan todos de ese valor, así que no hace falta ningún otro cambio. La fila flex puede necesitar una separación más ajustada o dimensiones de campo más pequeñas en pantallas muy reducidas al usar 8 dígitos.
¿Cómo conecto este componente a un backend real de verificación TOTP o SMS?
Sustituye la simulación handleSubmit por una función async que envíe el código a tu API (p. ej. POST /auth/verify-otp). Si tiene éxito, actualiza el estado verified; ante una respuesta 4xx, activa el estado error y, opcionalmente, vacía los campos. El handler de reenvío llama a tu endpoint de envío de código y reinicia la cuenta atrás.