Velocity UI
Loading…
Menu

Smart OTP Input

A polished set of input boxes for 2FA codes that auto-focuses, handles pasting, and validates with a success animation.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add smart-otp-input

Source Code

smart-otp-input.tsx
'use client'

import React, { useRef, useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence, useAnimationControls } from 'framer-motion'
import { cn } from '@/lib/utils'

export default function SmartOtpInput() {
  const [otp, setOtp] = useState<string[]>(new Array(6).fill(''))
  const [activeIndex, setActiveIndex] = useState(0)
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
  const [timer, setTimer] = useState(59)
  const [showToast, setShowToast] = useState(false)
  
  const inputRefs = useRef<(HTMLInputElement | null)[]>([])
  const controls = useAnimationControls()

  useEffect(() => {
    inputRefs.current[0]?.focus()
    const interval = setInterval(() => setTimer((p) => (p > 0 ? p - 1 : 0)), 1000)
    return () => clearInterval(interval)
  }, [])

  const handleVerify = useCallback(async (code: string) => {
    setStatus('loading')
    await new Promise(r => setTimeout(r, 1200)) 

    if (code === "123456") {
      setStatus('success')
      setShowToast(true)
    } else {
      setStatus('error')
      await controls.start({
        x: [-6, 6, -6, 6, 0],
        transition: { duration: 0.4 }
      })
      setTimeout(() => {
        setOtp(new Array(6).fill(''))
        setStatus('idle')
        setActiveIndex(0)
        inputRefs.current[0]?.focus()
      }, 500)
    }
  }, [controls])

  const handleChange = (val: string, index: number) => {
    if (status !== 'idle') return
    const char = val.replace(/[^0-9]/g, '').slice(-1)
    const newOtp = [...otp]
    newOtp[index] = char
    setOtp(newOtp)

    if (char && index < 5) {
      setActiveIndex(index + 1)
      inputRefs.current[index + 1]?.focus()
    }
    if (newOtp.join('').length === 6) handleVerify(newOtp.join(''))
  }

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    if (e.key === 'Backspace' && !otp[index] && index > 0) {
      const newOtp = [...otp]
      newOtp[index - 1] = ''
      setOtp(newOtp)
      setActiveIndex(index - 1)
      inputRefs.current[index - 1]?.focus()
    }
  }

  return (
    <div className="relative w-full max-w-sm mx-auto px-4 py-12 flex flex-col items-center">
      
      {/* Top Floating Feedback (The Island) */}
      <div className="absolute -top-4 left-0 right-0 flex justify-center h-10 pointer-events-none">
        <AnimatePresence>
          {showToast && (
            <motion.div
              initial={{ y: 10, opacity: 0, scale: 0.9 }}
              animate={{ y: 0, opacity: 1, scale: 1 }}
              exit={{ y: 10, opacity: 0, scale: 0.9 }}
              className="bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 px-4 py-1.5 rounded-full shadow-lg flex items-center gap-2"
            >
              <div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
              <span className="text-[13px] font-semibold tracking-tight">Verified</span>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <header className="text-center mb-12 space-y-1">
        <h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100">
          Security Code
        </h1>
        <p className="text-[15px] text-zinc-400 font-medium">
          Enter the code sent to your device
        </p>
      </header>

      {/* Simplified Premium Field Group */}
      <motion.div animate={controls} className="flex justify-center gap-4 mb-14">
        {otp.map((digit, index) => (
          <div key={index} className="relative w-10">
            <div className="flex flex-col items-center gap-3">
              <motion.div
                animate={{
                  y: digit ? 0 : 4,
                  scale: activeIndex === index ? 1.1 : 1,
                  color: status === 'error' ? '#EF4444' : status === 'success' ? '#10B981' : 'inherit'
                }}
                className="h-12 flex items-center justify-center text-[36px] font-light tabular-nums text-zinc-900 dark:text-white"
              >
                {digit || <span className="text-zinc-200 dark:text-zinc-800"></span>}
              </motion.div>

              {/* Minimal Line Indicator */}
              <motion.div
                animate={{
                  height: activeIndex === index ? '2.5px' : '1.5px',
                  width: '100%',
                  backgroundColor: activeIndex === index ? '#007AFF' : '#E5E7EB',
                  opacity: activeIndex === index ? 1 : 0.5
                }}
                className="rounded-full"
              />
            </div>

            <input
              ref={(el) => (inputRefs.current[index] = el)}
              type="text"
              inputMode="numeric"
              autoComplete="one-time-code"
              value={digit}
              onChange={(e) => handleChange(e.target.value, index)}
              onKeyDown={(e) => handleKeyDown(e, index)}
              onFocus={() => setActiveIndex(index)}
              className="absolute inset-0 w-full h-full opacity-0 cursor-default"
              disabled={status !== 'idle'}
            />
          </div>
        ))}
      </motion.div>

      <footer className="h-10 flex items-center justify-center">
        <AnimatePresence mode="wait">
          {status === 'loading' ? (
            <motion.div 
              key="loading"
              initial={{ opacity: 0 }} 
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              className="flex gap-1.5"
            >
              {[0, 1, 2].map((i) => (
                <motion.div
                  key={i}
                  animate={{ opacity: [0.2, 1, 0.2] }}
                  transition={{ repeat: Infinity, duration: 1, delay: i * 0.2 }}
                  className="w-1.5 h-1.5 bg-zinc-300 rounded-full"
                />
              ))}
            </motion.div>
          ) : (
            <motion.button
              key="resend"
              whileTap={{ scale: 0.97 }}
              disabled={timer > 0}
              onClick={() => timer === 0 && setTimer(59)}
              className={cn(
                "text-[14px] font-semibold tracking-tight transition-colors",
                timer > 0 ? "text-zinc-300" : "text-[#007AFF] hover:opacity-80 active:opacity-60"
              )}
            >
              Resend Code {timer > 0 && <span className="tabular-nums font-normal opacity-50 ml-1">in {timer}s</span>}
            </motion.button>
          )}
        </AnimatePresence>
      </footer>
    </div>
  )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
lengthnumber-Number of OTP digits
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.