Forgeui
uianimated-otpsource: ok · refreshed
Animated OTP
Animated OTP input with glowing digit transitions, inspired by Clerk’s authentication UI and rebuilt with custom behavior.
npx shadcn add "https://forgeui.in/r/animated-otp.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 5:35:22 AM
registry/components/animatedotp/animatedotp.tsx
"use client";
import { cn } from "@/lib/utils";
import { motion } from "motion/react";
import React, { useEffect, useState } from "react";
type AnimatedOTPProps = {
cardTitle?: string;
cardDescription?: string;
digits?: string;
};
const AnimatedOTP = ({
cardTitle = "Secure Access",
cardDescription = "Protect accounts with a one-time password, auto-applied during every user login for enhanced security.",
digits = "483197",
}: AnimatedOTPProps) => {
const [animationKey, setAnimationKey] = useState(0);
const delayTime = 3500;
useEffect(() => {
const interval = setInterval(() => {
setAnimationKey((prev) => prev + 1);
}, delayTime);
return () => clearInterval(interval);
}, [delayTime]);
return (
<OTPinput
key={animationKey}
cardTitle={cardTitle}
cardDescription={cardDescription}
digits={digits}
/>
);
};
export default AnimatedOTP;
const OTPinput = ({
digits = "483197",
cardTitle,
cardDescription,
}: AnimatedOTPProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [fadeOut, setFadeOut] = useState(false);
useEffect(() => {
if (activeIndex > digits.length - 1) return;
const interval = setInterval(() => {
setActiveIndex((prev) => prev + 1);
}, 400);
if (activeIndex === digits.length - 1) {
setTimeout(() => {
setFadeOut(true);
}, 450);
}
return () => clearInterval(interval);
}, [activeIndex, digits.length]);
return (
<div
className={cn(
"relative",
"flex items-center justify-center",
"h-56 w-full max-w-87.5 rounded-md",
"bg-neutral-50 ring-1 ring-black/10 dark:bg-neutral-900 dark:ring-neutral-800",
"shadow-sm shadow-black/10",
)}
>
<div className="absolute top-[25%] left-1/2 -translate-x-1/2">
<div className="flex w-full items-center justify-center gap-3">
{digits.split("").map((digit, idx) => (
<div
key={idx}
className={cn(
"text-primary relative flex h-10 w-8 items-center justify-center rounded-md border border-neutral-200 bg-linear-to-br from-neutral-50 to-white dark:border-none dark:from-neutral-800 dark:to-neutral-800",
"shadow-[0_1px_2px_rgb(0,0,0,0.1)]",
)}
>
<motion.div
className="absolute inset-0 rounded-md border border-cyan-400"
initial={{
opacity: 0,
scale: 1,
filter: "blur(0px)",
}}
animate={{
opacity: [0, 1, 0],
scale: [0.85, 1.3],
filter: "blur(2px)",
}}
transition={{
duration: 0.5,
ease: "easeInOut",
delay: 2.25,
}}
style={{
boxShadow: "inset 0 0 12px rgba(34, 211, 238, 0.5)",
}}
/>
{activeIndex === idx && (
<motion.div
key={idx}
layoutId="glow"
className="absolute inset-0 rounded-md border border-cyan-400"
initial={
idx === 0 ? { opacity: 0, scale: 1.7 } : { scale: 1.7 }
}
animate={idx === 0 ? { opacity: 1, scale: 1 } : { scale: 1 }}
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
style={{
boxShadow: "inset 0 0 12px rgba(34, 211, 238, 0.6)",
}}
>
<svg
viewBox=" 0 0 20 20"
className="absolute inset-0 h-full w-full"
strokeWidth="0.4"
>
<path
d="M 3 19 h 14"
className="stroke-cyan-400 dark:stroke-cyan-500"
/>
</svg>
</motion.div>
)}
<motion.span
initial={{
opacity: 0,
}}
animate={{
opacity: fadeOut ? 0 : 1,
}}
transition={{
duration: fadeOut ? 0.2 : 0.3,
ease: "easeInOut",
delay: fadeOut ? 0 : idx * 0.43,
}}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
>
{digit}
</motion.span>
</div>
))}
</div>
</div>
<div className="absolute bottom-4 left-0 w-full px-3">
<h3 className="text-primary text-sm font-semibold">{cardTitle}</h3>
<p className="mt-2 text-xs text-neutral-600 dark:text-neutral-400">
{cardDescription}
</p>
</div>
</div>
);
};