OTP Input
Auto-jumping digit boxes with paste support, backspace handling, and animated focus glow
inputotpverificationcodeformmobile
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useState, useRef, useCallback } from "react";
import { motion } from "framer-motion";
interface OtpInputProps {
length?: number;
onComplete?: (code: string) => void;
className?: string;
}
export default function OtpInput({
length = 6,
onComplete,
className = "",
}: OtpInputProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const [activeIndex, setActiveIndex] = useState(-1);
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
const focusInput = useCallback((index: number) => {
const clamped = Math.max(0, Math.min(index, length - 1));
inputsRef.current[clamped]?.focus();
}, [length]);
const handleChange = (index: number, value: string) => {
// Only accept digits
const digit = value.replace(/\D/g, "").slice(-1);
const newValues = [...values];
newValues[index] = digit;
setValues(newValues);
if (digit && index < length - 1) {
focusInput(index + 1);
}
const code = newValues.join("");
if (code.length === length && !newValues.includes("")) {
onComplete?.(code);
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === "Backspace") {
if (!values[index] && index > 0) {
focusInput(index - 1);
const newValues = [...values];
newValues[index - 1] = "";
setValues(newValues);
} else {
const newValues = [...values];
newValues[index] = "";
setValues(newValues);
}
} else if (e.key === "ArrowLeft" && index > 0) {
focusInput(index - 1);
} else if (e.key === "ArrowRight" && index < length - 1) {
focusInput(index + 1);
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
if (!pasted) return;
const newValues = [...values];
for (let i = 0; i < pasted.length; i++) {
newValues[i] = pasted[i];
}
setValues(newValues);
const nextEmpty = newValues.findIndex((v) => !v);
focusInput(nextEmpty === -1 ? length - 1 : nextEmpty);
if (pasted.length === length) {
onComplete?.(pasted);
}
};
return (
<div className={`flex items-center gap-3 ${className}`}>
{values.map((value, index) => (
<motion.div
key={index}
animate={{
scale: activeIndex === index ? 1.08 : 1,
borderColor:
activeIndex === index
? "rgba(99, 102, 241, 0.8)"
: value
? "rgba(99, 102, 241, 0.3)"
: "rgba(255, 255, 255, 0.15)",
}}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className="relative"
>
{/* Glow behind active */}
{activeIndex === index && (
<motion.div
layoutId="otp-glow"
className="absolute -inset-1 rounded-xl bg-indigo-500/20 blur-md"
transition={{ type: "spring", stiffness: 300, damping: 25 }}
/>
)}
<input
ref={(el) => { inputsRef.current[index] = el; }}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={1}
value={value}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onFocus={() => setActiveIndex(index)}
onBlur={() => setActiveIndex(-1)}
onPaste={handlePaste}
className="relative z-10 h-14 w-12 rounded-xl border bg-gray-900 text-center text-xl font-bold text-white outline-none transition-colors"
style={{
borderColor:
activeIndex === index
? "rgba(99, 102, 241, 0.8)"
: value
? "rgba(99, 102, 241, 0.3)"
: "rgba(255, 255, 255, 0.15)",
}}
/>
{/* Filled dot indicator */}
{value && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -bottom-2 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-indigo-400"
/>
)}
</motion.div>
))}
{/* Separator after 3rd digit */}
{length === 6 && (
<div
className="absolute"
style={{ display: "none" }}
aria-hidden="true"
/>
)}
</div>
);
}