Créer une saisie OTP 2FA en React avec auto-focus et paste support
Un composant OTP 2FA React affiche N champs d'un seul chiffre, avance le focus automatiquement à chaque frappe, recule sur Backspace, et gère le collage depuis le presse-papiers en distribuant les chiffres. Framer Motion anime l'entrée de la carte, le stagger des champs et le passage vers l'écran de succès via AnimatePresence.
- Stack : React 18 + Framer Motion 11 + Lucide React, ~310 lignes, aucune librairie supplémentaire.
- APIs React clés : useState, useRef, useCallback, useEffect, useInView.
- Paste presse-papiers : supprime les non-chiffres, remplit les champs de gauche à droite, focus le prochain slot vide.
- Le compte à rebours de renvoi utilise une chaîne setTimeout d'une seconde qui se nettoie au démontage.
- Accessible : inputMode='numeric' déclenche le clavier numérique sur mobile ; chaque champ est focusable individuellement.
Cette section auth propose un écran de vérification deux facteurs complet : six champs OTP individuels, progression automatique du focus, navigation clavier totale, coller depuis le presse-papiers, compte à rebours de renvoi, et un état de succès animé. Elle soigne les micro-interactions que les utilisateurs remarquent, la bordure qui s'illumine à la frappe, l'entrée fluide du message d'erreur, l'icône cadenas qui grossit à la validation.
Anatomie
La mise en page est une colonne centrée d'une largeur maximale de 400px. En haut, une icône ShieldCheck dans un carré arrondi couleur accent, suivie du titre et du sous-titre. Dessous, les six inputs OTP forment une ligne flex avec un écart de 8px ; chaque input fait 48px de large et 56px de haut. Un bouton de validation prend toute la largeur, passant d'un état désactivé grisé à un fond accent quand tous les champs sont remplis. La ligne de renvoi et le lien retour se trouvent en dessous. En cas de succès, AnimatePresence remplace le formulaire par une carte avec une icône Lock qui grossit à l'animation.
Comment ça marche
Chaque champ contient exactement un caractère ; le handler onChange supprime les non-chiffres, écrit la valeur dans un tableau de chaînes, puis appelle focus sur inputRefs[index + 1] quand le champ est rempli. Le handler Backspace vérifie si le champ courant est vide, si oui, il déplace le focus vers inputRefs[index - 1], permettant une édition arrière fluide. Le paste n'est géré que sur le premier champ : le texte du presse-papiers est nettoyé en chiffres uniquement, distribué dans le tableau, puis le focus atterrit sur le prochain slot vide. Le compte à rebours passe par un useEffect qui planifie un timeout d'une seconde et décrémente l'état ; l'effet se nettoie entre chaque tick pour éviter les accumulations. Framer Motion fournit deux couches d'animation : une entrée de carte (opacity 0→1, y 30→0 avec ease spring) et un stagger par digit (délai = index × 0,05s) pour que les champs apparaissent en cascade. AnimatePresence en mode 'wait' gère la transition formulaire-succès sans décalage de mise en page.
Comment le coder en React
Créer l'état tableau de chiffres et les refs
Initialise un tableau de chaînes avec Array(codeLength).fill('') et un tableau inputRefs parallèle de même longueur. Le tableau de refs permet d'appeler focus() impérativement sur n'importe quel champ par index, ce que l'état React seul ne peut pas faire.
const [code, setCode] = useState<string[]>(Array(codeLength).fill("")); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);Connecter onChange, Backspace et paste
Dans onChange, rejette les non-chiffres avec un guard regex, écrit le dernier caractère dans le bon slot, puis avance le focus. Dans onKeyDown, quand Backspace se déclenche sur un champ vide, recule le focus d'un cran. Attache le handler paste uniquement au premier input, parse les chiffres, remplit le tableau, puis focus le prochain index vide.
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(); };Animer les champs avec un stagger par digit
Remplace le simple <input> par <motion.input> et ajoute une paire initial/animate qui lit depuis le hook useInView. Mets le delay à i * 0,05 pour que chaque champ entre en cascade 50ms après le précédent. Cela rend l'entrée vivante sans surcharger l'utilisateur.
<motion.input initial={{ opacity: 0, y: 10 }} animate={inView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.3, delay: i * 0.05, ease: EASE }} />Passer à l'état succès avec AnimatePresence
Enveloppe le formulaire et la carte de succès dans <AnimatePresence mode='wait'>. Donne une key unique à chacun pour que Framer Motion sorte l'ancienne vue avant de monter la nouvelle. La carte de succès utilise une animation scale imbriquée sur l'icône Lock (scale 0→1) pour renforcer la métaphore de vérification.
Quand l'utiliser
Utilise ce composant comme étape 2FA dédiée dans un flux d'authentification multi-écrans, directement après la connexion par mot de passe quand l'utilisateur a configuré SMS ou TOTP. Il convient aux tableaux de bord SaaS, panneaux d'administration, services bancaires, et tout produit nécessitant un second facteur. À éviter quand le 2FA est optionnel ou pas encore implémenté ; afficher cet écran à des utilisateurs non-inscrits créera de la confusion. Sur mobile, le clavier numérique se déclenche automatiquement via inputMode='numeric', mais teste le comportement du paste dans Safari car l'accès presse-papiers y a des particularités connues.
Utilisé par
- GitHub, Utilise une disposition en boîtes OTP pour le 2FA par SMS et app d'authentification, avec avance automatique du focus à chaque chiffre.
- Stripe, Les codes de vérification à la connexion utilisent des champs individuels avec le même mécanisme de collage et distribution.
- Linear, Écran 2FA centré minimaliste avec inputs par chiffre et lien de renvoi, cohérent avec leur UX sobre orientée clavier.
- Vercel, La saisie OTP lors de la connexion d'équipe utilise des boîtes individuelles avec avance automatique du focus et support du backspace.
FAQ
Comment fonctionne le handler de paste sur les six champs ?
L'écouteur de paste est attaché uniquement au premier input. Au déclenchement, il appelle e.preventDefault() pour empêcher le navigateur d'insérer le texte brut, extrait seulement les caractères chiffres de la chaîne du presse-papiers, la découpe à codeLength, puis mappe chaque chiffre dans le tableau code. Le focus se déplace ensuite vers le premier slot vide, ou vers le dernier champ si tous sont remplis.
Pourquoi utiliser des inputs individuels plutôt qu'un seul champ texte ?
Des inputs séparés offrent une progression visuelle (chaque boîte se remplit à la frappe), une navigation clavier naturelle via Backspace, et le contrôle sur ce qu'affiche le clavier virtuel sur mobile. Un seul input caché est une alternative, mais nécessite une couche de rendu custom, ajoutant de la complexité sans réel bénéfice UX pour un code de longueur fixe.
Puis-je changer la longueur du code de 6 à 4 ou 8 chiffres ?
Passe une prop codeLength différente. L'état tableau, la boucle de rendu des inputs et la validation dérivent tous de cette valeur, aucune autre modification n'est nécessaire. La ligne flex peut nécessiter un écart plus serré ou des dimensions de champ plus petites sur très petits écrans avec 8 chiffres.
Comment connecter ce composant à un vrai backend TOTP ou SMS ?
Remplace la simulation handleSubmit par une fonction async qui envoie le code à ton API (ex. POST /auth/verify-otp). En cas de succès, mets à jour l'état verified ; sur une réponse 4xx, active l'état error et efface optionnellement les champs. Le handler de renvoi appelle ton endpoint d'envoi de code et réinitialise le compte à rebours.