Animated OTP
One-time code field where each box animates as you type.
npx shadcn@latest add https://noechague-site.vercel.app/r/animated-otp.jsonanimated-otp.tsx
"use client"
import { useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { OTPInput, type SlotProps } from "input-otp"
import { cn } from "@/lib/utils"
function Slot({ char, isActive, reduced }: SlotProps & { reduced: boolean }) {
return (
<div
// Stock Tailwind classes only: this file is installed into other people's
// projects, where the site's own scale (gray-1200, preview-bg) generates
// no CSS at all and the boxes would lose their border and text colour.
// neutral-400 at rest, not a lighter step: the border is the only thing
// that says how many boxes the field expects, and WCAG 1.4.11 asks 3:1
// for that boundary.
className={cn(
"relative flex h-11 w-10 items-center justify-center rounded-lg",
"border border-neutral-400 bg-white font-medium text-base text-neutral-900",
"transition-colors duration-200 ease-out",
isActive && "border-neutral-900",
)}
>
<AnimatePresence mode="popLayout">
{char !== null && (
<motion.span
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(4px)" }}
initial={reduced ? { opacity: 1 } : { opacity: 0, y: 8, filter: "blur(4px)" }}
key={char}
transition={reduced ? { duration: 0 } : { type: "spring", duration: 0.35, bounce: 0.2 }}
>
{char}
</motion.span>
)}
</AnimatePresence>
{isActive && char === null && (
// The caret was the one animation in this file with no `reduced` guard,
// and a global CSS `prefers-reduced-motion` block cannot reach it:
// motion writes through WAAPI, not CSS animations. Under the preference
// the caret is now a steady bar, which still marks the active box.
<motion.span
animate={reduced ? { opacity: 1 } : { opacity: [1, 0, 1] }}
className="h-5 w-px bg-neutral-900"
transition={
reduced
? { duration: 0 }
: { duration: 1.1, repeat: Number.POSITIVE_INFINITY, ease: "linear" }
}
/>
)}
</div>
)
}
export function AnimatedOtp({
length = 6,
onComplete,
}: {
length?: number
onComplete?: (value: string) => void
}) {
const [value, setValue] = useState("")
const reduced = useReducedMotion() ?? false
return (
<OTPInput
aria-label="Verification code"
containerClassName="flex items-center gap-2"
maxLength={length}
onChange={setValue}
onComplete={onComplete}
render={({ slots }) => (
<div className="flex items-center gap-1.5">
{slots.map((slot, i) => (
<Slot key={i} {...slot} reduced={reduced} />
))}
</div>
)}
value={value}
/>
)
}