OTP Input

A six-digit OTP input component with smooth animations, auto-focus navigation, and visual feedback for verification success or errors.

Install dependencies

npm i framer-motion lucide-react

Utility function

Create a file lib/utils.ts and paste this code

import { ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }

Component

Create a file otp-input.tsx in your components folder and paste this code

'use client'; import React, { useEffect, useState } from 'react'; import { AnimatePresence, motion, Transition, useAnimationControls } from 'framer-motion'; import { cn } from '@/lib/utils'; import { Check } from 'lucide-react'; const OTPSuccess = () => { return ( <div className="flex items-center gap-4 w-full"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="w-9 h-10 bg-green-500 ring-4 ring-green-800 text-white flex items-center justify-center rounded-lg" > <Check size={16} strokeWidth={3} /> </motion.div> <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }} className="text-green-500 font-medium" > OTP Verified Successfully! </motion.p> </div> ); }; const OTPError = () => { return ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2 }} className="text-center text-red-400 font-medium mt-2" > Invalid OTP </motion.div> ); }; const OTPInputBox = ({ index, verifyOTP, state }: { index: number; verifyOTP: () => boolean | null; state: 'idle' | 'error' | 'success'; }) => { const animationControls = useAnimationControls(); const springTransition: Transition = { type: 'spring', stiffness: 700, damping: 20, delay: index * 0.05 }; const noDelaySpringTransition: Transition = { type: 'spring', stiffness: 700, damping: 20 }; const slowSuccessTransition: Transition = { type: 'spring', stiffness: 300, damping: 30, delay: index * 0.06 }; useEffect(() => { animationControls.start({ opacity: 1, y: 0, transition: springTransition }); return () => { animationControls.stop(); }; }, []); useEffect(() => { if (state === 'success') { const transitionX = index * 44; animationControls.start({ x: -transitionX, transition: slowSuccessTransition }); } }, [state]); const onFocus = () => { animationControls.start({ y: -5, transition: noDelaySpringTransition }); }; const onBlur = () => { animationControls.start({ scale: 1, y: 0, transition: noDelaySpringTransition }); }; const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const value = (e.target as HTMLInputElement).value; if (e.key === 'Backspace' && value) { (e.target as HTMLInputElement).value = ''; } else if (e.key === 'Backspace' && !value && index > 0) { const prevInput = document.getElementById('input-' + (index - 1)); if (prevInput) { prevInput.focus(); } } else if (index > 0 && e.key === 'ArrowLeft') { const prevInput = document.getElementById('input-' + (index - 1)); if (prevInput) { prevInput.focus(); setTimeout(() => { (prevInput as HTMLInputElement).setSelectionRange(1, 1); }, 0); } } else if (index < 5 && e.key === 'ArrowRight') { const nextInput = document.getElementById('input-' + (index + 1)); if (nextInput) { nextInput.focus(); setTimeout(() => { (nextInput as HTMLInputElement).setSelectionRange(1, 1); }, 0); } } }; const onInput = (e: React.ChangeEvent<HTMLInputElement>) => { const lastDigit = e.target.value[e.target.value.length - 1]; if (lastDigit) { e.target.value = lastDigit; } if (e.target.value.length === 1 && index < 5) { const nextInput = document.getElementById('input-' + (index + 1)); if (nextInput) { nextInput.focus(); } } const verified = verifyOTP(); if (verified === null) return; if (verified === true) e.target.blur(); }; const onPaste = async (e: React.ClipboardEvent<HTMLInputElement>) => { e.preventDefault(); const text = e.clipboardData.getData('text/plain'); const digits = text .trim() .split('') .filter((char) => ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(char.trim())); for (let i = 0; i < digits.length; i++) { const digit = digits[i]; const input = document.getElementById('input-' + i); if (input) { input.focus(); (input as HTMLInputElement).value = digit; await new Promise((resolve) => setTimeout(resolve, 75)); input.blur(); } if (i === digits.length - 1) { verifyOTP(); } } }; return ( <motion.div className={cn( 'w-9 h-10 bg-background/50 rounded-lg ring-2 ring-transparent focus-within:shadow-inner overflow-hidden', state === 'error' ? 'ring-red-400' : state === 'success' ? 'ring-green-500' : 'focus-within:ring-blue-500' )} initial={{ opacity: 0, y: 10 }} animate={animationControls} > <motion.input id={'input-' + index} onInput={onInput} onKeyDown={onKeyDown} onPaste={onPaste} onFocus={onFocus} onBlur={onBlur} placeholder="0" className="border-none outline-none w-9 h-10 text-center bg-white/10 placeholder:text-white/15 caret-transparent" disabled={state === 'success'} ></motion.input> </motion.div> ); }; const OTPInput = () => { const [state, setState] = useState<'idle' | 'error' | 'success'>('idle'); const animationControls = useAnimationControls(); const noDelaySimpleTransition: Transition = { duration: 0.1, ease: 'easeInOut' }; const errorAnimation = async () => { setState('error'); await animationControls.start({ x: [0, 3, -3, 3, -3, 0], transition: noDelaySimpleTransition }); }; const verifyOTP = () => { let code = ''; for (let i = 0; i < 6; i++) { const input = document.getElementById('input-' + i); if (input) { code += (input as HTMLInputElement).value; } } if (code.length === 6) { if (code === '424242') { setState('success'); return true; } else { errorAnimation(); return false; } } else { setState('idle'); return null; } }; return ( <div className="flex flex-col items-center justify-center gap-2"> <div className="relative"> <AnimatePresence> {state !== 'success' && ( <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute text-center w-full bottom-full left-0 mb-4 font-medium text-lg" > OTP Verification </motion.p> )} </AnimatePresence> <motion.div animate={animationControls} className="flex items-center justify-center gap-2"> {Array.from({ length: 6 }).map((_, index) => { return ( <OTPInputBox key={'input-' + index} index={index} verifyOTP={verifyOTP} state={state} /> ); })} </motion.div> {state === 'success' && ( <div className="absolute inset-0 top-0 left-0"> <OTPSuccess /> </div> )} {state === 'error' && ( <div className="absolute inset-0 top-full left-0"> <OTPError /> </div> )} </div> <div onClick={() => { navigator.clipboard.writeText('424242'); }} className="absolute flex items-center gap-1 top-4 right-4 text-sm px-2 py-1 cursor-pointer bg-white/5 hover:bg-white/10 active:bg-white/15 select-none rounded-lg" > Copy Password </div> </div> ); }; export default OTPInput;

Usage

<OTPInput/>

OTP Verification

Copy Password