Animated O T P Input
Animated O T P Input is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
forms
What it is
Animated O T P Input is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion lucide-react input-otp
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Animated O T P Input
"use client";
import { cn } from "@/lib/utils";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useContext, useEffect, useState } from "react";
// Animation constants
const EASE_OUT_QUINT_X1 = 0.22;
const EASE_OUT_QUINT_Y1 = 1;
const EASE_OUT_QUINT_X2 = 0.36;
const EASE_OUT_QUINT_Y2 = 1;
const EASE_OUT_QUINT = [
EASE_OUT_QUINT_X1,
EASE_OUT_QUINT_Y1,
EASE_OUT_QUINT_X2,
EASE_OUT_QUINT_Y2,
] as const;
const ANIMATION_DURATION_SHORT = 0.1;
const ANIMATION_DURATION_MEDIUM = 0.15;
const ANIMATION_DURATION_STANDARD = 0.2;
const ANIMATION_DURATION_LONG = 0.3;
const STAGGER_DELAY = 0.05;
const SCALE_FILLED = 1.05;
const SCALE_HOVER = 1.02;
const SCALE_TAP = 0.98;
const INITIAL_SCALE = 0.8;
const INITIAL_Y = 10;
const SEPARATOR_DELAY = 0.15;
export interface AnimatedInputOTPProps {
"aria-describedby"?: string;
"aria-label"?: string;
className?: string;
containerClassName?: string;
maxLength?: number;
onChange?: (value: string) => void;
onComplete?: (value: string) => void;
value?: string;
}
function AnimatedInputOTP({
className,
containerClassName,
value,
onChange,
onComplete,
maxLength = 6,
children,
...props
}: AnimatedInputOTPProps & { children: ReactNode }) {
const handleChange = (newValue: string) => {
// Only allow numeric characters
const numericValue = newValue.replace(/[^0-9]/g, "");
onChange?.(numericValue);
};
return (
<OTPInput
aria-describedby={props["aria-describedby"]}
aria-label={props["aria-label"] || "One-time password input"}
className={cn("disabled:cursor-not-allowed", className)}
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
data-slot="input-otp"
maxLength={maxLength}
onChange={handleChange}
onComplete={onComplete}
value={value}
{...props}
>
{children}
</OTPInput>
);
}
interface AnimatedInputOTPGroupProps {
children?: ReactNode;
className?: string;
}
function AnimatedInputOTPGroup({
className,
children,
}: AnimatedInputOTPGroupProps) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
className={cn("flex items-center gap-2", className)}
data-slot="input-otp-group"
initial={
shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: INITIAL_Y }
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: ANIMATION_DURATION_LONG,
ease: EASE_OUT_QUINT,
}
}
>
{children}
</motion.div>
);
}
interface AnimatedInputOTPSlotProps {
className?: string;
index: number;
}
function AnimatedInputOTPSlot({ index, className }: AnimatedInputOTPSlotProps) {
const inputOTPContext = useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
const [isFilled, setIsFilled] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
if (char && !isFilled) {
setIsFilled(true);
} else if (!char && isFilled) {
setIsFilled(false);
}
}, [char, isFilled]);
return (
<motion.div
animate={
shouldReduceMotion
? { opacity: 1 }
: {
opacity: 1,
scale: isFilled ? SCALE_FILLED : 1,
y: 0,
}
}
className={cn(
"relative flex h-9 w-9 items-center justify-center rounded-md border border-zinc-300 bg-background text-sm shadow-sm outline-none transition-all aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:border-zinc-700 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className
)}
data-active={isActive}
data-slot="input-otp-slot"
initial={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 0, scale: INITIAL_SCALE, y: INITIAL_Y }
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
delay: index * STAGGER_DELAY,
duration: ANIMATION_DURATION_STANDARD,
ease: EASE_OUT_QUINT,
scale: {
duration: ANIMATION_DURATION_MEDIUM,
ease: EASE_OUT_QUINT,
},
}
}
whileHover={
shouldReduceMotion
? {}
: {
scale: SCALE_HOVER,
transition: {
duration: ANIMATION_DURATION_MEDIUM,
ease: EASE_OUT_QUINT,
},
}
}
whileTap={
shouldReduceMotion
? {}
: {
scale: SCALE_TAP,
transition: {
duration: ANIMATION_DURATION_SHORT,
ease: EASE_OUT_QUINT,
},
}
}
>
<AnimatePresence mode="wait">
{char ? (
<motion.span
animate={
shouldReduceMotion
? { opacity: 1, scale: 1 }
: { opacity: 1, rotateY: 0, scale: 1 }
}
className="font-medium"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, rotateY: 90, scale: 0.5 }
}
initial={
shouldReduceMotion
? { opacity: 1, scale: 1 }
: { opacity: 0, rotateY: -90, scale: 0.5 }
}
key={char}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: ANIMATION_DURATION_STANDARD,
ease: EASE_OUT_QUINT,
}
}
>
{char}
</motion.span>
) : null}
</AnimatePresence>
{hasFakeCaret && !shouldReduceMotion && (
<motion.div
animate={{ opacity: 1 }}
className="pointer-events-none absolute inset-0 flex items-center justify-center"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{ duration: ANIMATION_DURATION_SHORT }}
>
<motion.div
animate={{ opacity: [0, 1, 0] }}
className="h-4 w-px bg-foreground"
transition={{
duration: 1,
ease: [0.645, 0.045, 0.355, 1],
repeat: Number.POSITIVE_INFINITY,
}}
/>
</motion.div>
)}
</motion.div>
);
}
function AnimatedInputOTPSeparator() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
data-slot="input-otp-separator"
initial={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 0, scale: INITIAL_SCALE }
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
delay: SEPARATOR_DELAY,
duration: ANIMATION_DURATION_LONG,
ease: EASE_OUT_QUINT,
}
}
>
<MinusIcon className="h-4 w-4 text-muted-foreground" />
</motion.div>
);
}
// Main component that combines everything
export function AnimatedOTPInput({
maxLength = 6,
className,
value,
onChange,
onComplete,
...props
}: AnimatedInputOTPProps) {
return (
<AnimatedInputOTP
className={className}
maxLength={maxLength}
onChange={onChange}
onComplete={onComplete}
value={value}
{...props}
>
<AnimatedInputOTPGroup>
<AnimatedInputOTPSlot index={0} />
<AnimatedInputOTPSlot index={1} />
<AnimatedInputOTPSlot index={2} />
</AnimatedInputOTPGroup>
<AnimatedInputOTPSeparator />
<AnimatedInputOTPGroup>
<AnimatedInputOTPSlot index={3} />
<AnimatedInputOTPSlot index={4} />
<AnimatedInputOTPSlot index={5} />
</AnimatedInputOTPGroup>
</AnimatedInputOTP>
);
}
export {
AnimatedInputOTP,
AnimatedInputOTPGroup,
AnimatedInputOTPSeparator,
AnimatedInputOTPSlot,
};
export default AnimatedOTPInput;