OTP Input

Auto-jumping digit boxes with paste support, backspace handling, and animated focus glow

inputotpverificationcodeformmobile
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { useState, useRef, useCallback } from "react";
import { motion } from "framer-motion";

interface OtpInputProps {
  length?: number;
  onComplete?: (code: string) => void;
  className?: string;
}

export default function OtpInput({
  length = 6,
  onComplete,
  className = "",
}: OtpInputProps) {
  const [values, setValues] = useState<string[]>(Array(length).fill(""));
  const [activeIndex, setActiveIndex] = useState(-1);
  const inputsRef = useRef<(HTMLInputElement | null)[]>([]);

  const focusInput = useCallback((index: number) => {
    const clamped = Math.max(0, Math.min(index, length - 1));
    inputsRef.current[clamped]?.focus();
  }, [length]);

  const handleChange = (index: number, value: string) => {
    // Only accept digits
    const digit = value.replace(/\D/g, "").slice(-1);
    const newValues = [...values];
    newValues[index] = digit;
    setValues(newValues);

    if (digit && index < length - 1) {
      focusInput(index + 1);
    }

    const code = newValues.join("");
    if (code.length === length && !newValues.includes("")) {
      onComplete?.(code);
    }
  };

  const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
    if (e.key === "Backspace") {
      if (!values[index] && index > 0) {
        focusInput(index - 1);
        const newValues = [...values];
        newValues[index - 1] = "";
        setValues(newValues);
      } else {
        const newValues = [...values];
        newValues[index] = "";
        setValues(newValues);
      }
    } else if (e.key === "ArrowLeft" && index > 0) {
      focusInput(index - 1);
    } else if (e.key === "ArrowRight" && index < length - 1) {
      focusInput(index + 1);
    }
  };

  const handlePaste = (e: React.ClipboardEvent) => {
    e.preventDefault();
    const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
    if (!pasted) return;

    const newValues = [...values];
    for (let i = 0; i < pasted.length; i++) {
      newValues[i] = pasted[i];
    }
    setValues(newValues);

    const nextEmpty = newValues.findIndex((v) => !v);
    focusInput(nextEmpty === -1 ? length - 1 : nextEmpty);

    if (pasted.length === length) {
      onComplete?.(pasted);
    }
  };

  return (
    <div className={`flex items-center gap-3 ${className}`}>
      {values.map((value, index) => (
        <motion.div
          key={index}
          animate={{
            scale: activeIndex === index ? 1.08 : 1,
            borderColor:
              activeIndex === index
                ? "rgba(99, 102, 241, 0.8)"
                : value
                  ? "rgba(99, 102, 241, 0.3)"
                  : "rgba(255, 255, 255, 0.15)",
          }}
          transition={{ type: "spring", stiffness: 400, damping: 25 }}
          className="relative"
        >
          {/* Glow behind active */}
          {activeIndex === index && (
            <motion.div
              layoutId="otp-glow"
              className="absolute -inset-1 rounded-xl bg-indigo-500/20 blur-md"
              transition={{ type: "spring", stiffness: 300, damping: 25 }}
            />
          )}
          <input
            ref={(el) => { inputsRef.current[index] = el; }}
            type="text"
            inputMode="numeric"
            autoComplete="one-time-code"
            maxLength={1}
            value={value}
            onChange={(e) => handleChange(index, e.target.value)}
            onKeyDown={(e) => handleKeyDown(index, e)}
            onFocus={() => setActiveIndex(index)}
            onBlur={() => setActiveIndex(-1)}
            onPaste={handlePaste}
            className="relative z-10 h-14 w-12 rounded-xl border bg-gray-900 text-center text-xl font-bold text-white outline-none transition-colors"
            style={{
              borderColor:
                activeIndex === index
                  ? "rgba(99, 102, 241, 0.8)"
                  : value
                    ? "rgba(99, 102, 241, 0.3)"
                    : "rgba(255, 255, 255, 0.15)",
            }}
          />
          {/* Filled dot indicator */}
          {value && (
            <motion.div
              initial={{ scale: 0 }}
              animate={{ scale: 1 }}
              className="absolute -bottom-2 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-indigo-400"
            />
          )}
        </motion.div>
      ))}

      {/* Separator after 3rd digit */}
      {length === 6 && (
        <div
          className="absolute"
          style={{ display: "none" }}
          aria-hidden="true"
        />
      )}
    </div>
  );
}