Animated OTP

One-time code field where each box animates as you type.

Working demo
npx shadcn@latest add https://noechague-site.vercel.app/r/animated-otp.json
animated-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}
    />
  )
}