Enter Verification Code
We've sent a code to your email
Didn't receive the code?
Installation
Copy and paste the following code into your project.
components/ui/creative-otp-input.tsx
"use client";
import { cn } from "@/lib/utils";
import React, { useRef, useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
type Variant = "default" | "neon" | "minimal" | "dots" | "underline";
type Status = "idle" | "success" | "error";
interface CreativeOTPInputProps {
length?: number;
onComplete?: (otp: string) => void;
className?: string;
variant?: Variant;
status?: Status;
}
const variants = {
default: {
input: {
base: "h-16 w-12 rounded-lg text-center text-2xl font-bold border-2 transition-all duration-200",
idle: "border-muted-foreground/20 dark:border-muted-foreground/10 bg-background dark:bg-background/80 focus:border-primary dark:focus:border-primary",
success:
"border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-950/30 text-green-600 dark:text-green-400",
error:
"border-red-500 dark:border-red-400 bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400",
},
container: "gap-2",
indicator: {
idle: "-right-1 -top-1 h-3 w-3 rounded-full bg-primary dark:bg-primary",
success:
"-right-1 -top-1 h-3 w-3 rounded-full bg-green-500 dark:bg-green-400",
error: "-right-1 -top-1 h-3 w-3 rounded-full bg-red-500 dark:bg-red-400",
},
},
neon: {
input: {
base: "h-16 w-12 rounded-lg text-center text-2xl font-bold border-2 transition-all duration-300",
idle: "border-primary/50 bg-primary/5 dark:border-primary/30 dark:bg-primary/10 shadow-[0_0_15px_rgba(var(--primary)/0.2)] dark:shadow-[0_0_15px_rgba(var(--primary)/0.3)] focus:border-primary focus:shadow-[0_0_25px_rgba(var(--primary)/0.5)] dark:focus:shadow-[0_0_25px_rgba(var(--primary)/0.6)]",
success:
"border-green-500/50 dark:border-green-400/30 bg-green-500/5 dark:bg-green-400/10 shadow-[0_0_15px_rgba(34,197,94,0.2)] dark:shadow-[0_0_15px_rgba(74,222,128,0.3)] text-green-600 dark:text-green-400",
error:
"border-red-500/50 dark:border-red-400/30 bg-red-500/5 dark:bg-red-400/10 shadow-[0_0_15px_rgba(239,68,68,0.2)] dark:shadow-[0_0_15px_rgba(248,113,113,0.3)] text-red-600 dark:text-red-400",
},
container: "gap-4",
indicator: {
idle: "right-0 top-0 h-full w-full rounded-lg bg-primary/10 dark:bg-primary/20",
success:
"right-0 top-0 h-full w-full rounded-lg bg-green-500/10 dark:bg-green-400/20",
error:
"right-0 top-0 h-full w-full rounded-lg bg-red-500/10 dark:bg-red-400/20",
},
},
minimal: {
input: {
base: "h-14 w-10 rounded-md text-center text-xl font-medium border-b-2 transition-all duration-200 bg-transparent",
idle: "border-muted-foreground/20 dark:border-muted-foreground/10 focus:border-primary",
success:
"border-green-500 dark:border-green-400 text-green-600 dark:text-green-400",
error:
"border-red-500 dark:border-red-400 text-red-600 dark:text-red-400",
},
container: "gap-3",
indicator: {
idle: "bottom-0 left-0 h-0.5 w-full bg-primary dark:bg-primary",
success: "bottom-0 left-0 h-0.5 w-full bg-green-500 dark:bg-green-400",
error: "bottom-0 left-0 h-0.5 w-full bg-red-500 dark:bg-red-400",
},
},
dots: {
input: {
base: "h-12 w-12 rounded-full text-center text-xl font-bold border-2 transition-all duration-200",
idle: "border-muted-foreground/20 dark:border-muted-foreground/10 bg-background dark:bg-background/80 focus:border-primary",
success:
"border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-950/30 text-green-600 dark:text-green-400",
error:
"border-red-500 dark:border-red-400 bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400",
},
container: "gap-4",
indicator: {
idle: "left-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20 dark:bg-primary/30",
success:
"left-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full bg-green-500/20 dark:bg-green-400/30",
error:
"left-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full bg-red-500/20 dark:bg-red-400/30",
},
},
underline: {
input: {
base: "h-16 w-12 text-center text-2xl font-bold border-b-4 transition-all duration-200 bg-transparent",
idle: "border-muted-foreground/20 dark:border-muted-foreground/10 focus:border-primary",
success:
"border-green-500 dark:border-green-400 text-green-600 dark:text-green-400",
error:
"border-red-500 dark:border-red-400 text-red-600 dark:text-red-400",
},
container: "gap-6",
indicator: {
idle: "bottom-0 left-0 h-1 w-full bg-primary dark:bg-primary",
success: "bottom-0 left-0 h-1 w-full bg-green-500 dark:bg-green-400",
error: "bottom-0 left-0 h-1 w-full bg-red-500 dark:bg-red-400",
},
},
};
export function CreativeOTPInput({
length = 6,
onComplete,
className,
variant = "default",
status = "idle",
}: CreativeOTPInputProps) {
const [otp, setOtp] = useState<string[]>(Array(length).fill(""));
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
useEffect(() => {
if (inputRefs.current[0]) {
inputRefs.current[0].focus();
}
}, []);
const handleChange = (index: number, value: string) => {
if (isNaN(Number(value))) return;
const newOtp = [...otp];
newOtp[index] = value.slice(-1);
setOtp(newOtp);
if (value && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
if (newOtp.every((val) => val !== "") && onComplete) {
onComplete(newOtp.join(""));
}
};
const handleKeyDown = (
index: number,
e: React.KeyboardEvent<HTMLInputElement>,
) => {
if (e.key === "Backspace" && !otp[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData("text").slice(0, length);
const newOtp = [...otp];
pastedData.split("").forEach((char, index) => {
if (index < length && !isNaN(Number(char))) {
newOtp[index] = char;
}
});
setOtp(newOtp);
if (newOtp.every((val) => val !== "") && onComplete) {
onComplete(newOtp.join(""));
}
};
const getInputAnimation = (index: number) => {
const baseAnimation = {
scale: focusedIndex === index ? 1.1 : 1,
};
switch (variant) {
case "neon":
return {
...baseAnimation,
boxShadow:
focusedIndex === index
? status === "success"
? "0 0 25px rgba(34,197,94,0.5)"
: status === "error"
? "0 0 25px rgba(239,68,68,0.5)"
: "0 0 25px rgba(var(--primary)/0.5)"
: undefined,
};
case "dots":
return {
...baseAnimation,
rotate: focusedIndex === index ? [0, -10, 10, 0] : 0,
};
case "minimal":
return {
y: focusedIndex === index ? -2 : 0,
};
case "underline":
return {
borderBottomWidth: focusedIndex === index ? "6px" : "4px",
};
default:
return {
...baseAnimation,
rotate: focusedIndex === index ? [0, -2, 2, 0] : 0,
};
}
};
return (
<div className={cn("flex flex-col items-center", className)}>
<div className={cn("flex", variants[variant].container)}>
{otp.map((digit, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.5 }}
animate={{
opacity: 1,
scale: 1,
...getInputAnimation(index),
}}
transition={{
duration: 0.2,
delay: index * 0.05,
}}
className="relative"
>
<input
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
onFocus={() => setFocusedIndex(index)}
onBlur={() => setFocusedIndex(null)}
className={cn(
variants[variant].input.base,
variants[variant].input[status],
"focus:outline-hidden",
)}
/>
<AnimatePresence>
{digit && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className={cn(
"absolute",
variants[variant].indicator[status],
)}
/>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</div>
);
}
Update the import paths to match your project setup.
import { CreativeOTPInput } from "@/components/ui/creative-otp-input";
Examples
Default OTP Input
<CreativeOTPInput length={6} onComplete={(otp) => console.log(otp)} />
Neon Style
The neon variant features glowing effects and dynamic shadows.
<CreativeOTPInput
variant="neon"
length={6}
onComplete={(otp) => console.log(otp)}
/>
Minimal Style
A clean, minimalist design with subtle animations.
<CreativeOTPInput
variant="minimal"
length={6}
onComplete={(otp) => console.log(otp)}
/>
Dots Style
Circular input fields with playful animations.
<CreativeOTPInput
variant="dots"
length={6}
onComplete={(otp) => console.log(otp)}
/>
Underline Style
Clean underline design with smooth transitions.
<CreativeOTPInput
variant="underline"
length={6}
onComplete={(otp) => console.log(otp)}
/>
With Status States
The component supports three states: idle, success, and error.
<CreativeOTPInput
variant="default"
status="success" // or "error" or "idle"
length={6}
onComplete={(otp) => {
// Handle validation
if (isValid(otp)) {
setStatus("success");
} else {
setStatus("error");
}
}}
/>
Props
Prop | Type | Default | Description |
---|---|---|---|
length | number | 6 | Number of OTP input fields |
variant | "default" | "neon" | "minimal" | "dots" | "underline" | "default" | Visual style variant |
status | "idle" | "success" | "error" | "idle" | Current status state |
onComplete | (otp: string) => void | - | Callback when all digits are entered |
className | string | - | Additional CSS classes |