OTP Input

One-time code boxes with auto-advance, paste and arrow keys.

Enter the code we sent to your phone

Tip: try pasting 123456

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/otp-input.tsx

    components/ui/otp-input.tsx
    "use client";
    
    import { motion } from "motion/react";
    import { useRef, useState, type ClipboardEvent, type KeyboardEvent } from "react";
    
    type OtpInputProps = {
      length?: number;
      onComplete?: (code: string) => void;
    };
    
    export function OtpInput({ length = 6, onComplete }: OtpInputProps) {
      const [values, setValues] = useState<string[]>(Array(length).fill(""));
      const refs = useRef<(HTMLInputElement | null)[]>([]);
    
      function update(next: string[]) {
        setValues(next);
        if (next.every(Boolean)) onComplete?.(next.join(""));
      }
    
      function onChange(i: number, raw: string) {
        const digit = raw.replace(/\D/g, "").slice(-1);
        const next = [...values];
        next[i] = digit;
        update(next);
        if (digit && i < length - 1) refs.current[i + 1]?.focus();
      }
    
      function onKeyDown(i: number, e: KeyboardEvent<HTMLInputElement>) {
        if (e.key === "Backspace" && !values[i] && i > 0) refs.current[i - 1]?.focus();
        if (e.key === "ArrowLeft" && i > 0) refs.current[i - 1]?.focus();
        if (e.key === "ArrowRight" && i < length - 1) refs.current[i + 1]?.focus();
      }
    
      function onPaste(e: ClipboardEvent<HTMLInputElement>) {
        const digits = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length).split("");
        if (!digits.length) return;
        e.preventDefault();
        const next = Array.from({ length }, (_, i) => digits[i] ?? "");
        update(next);
        refs.current[Math.min(digits.length, length - 1)]?.focus();
      }
    
      return (
        <div className="flex gap-2" role="group" aria-label="One-time code">
          {values.map((v, i) => (
            <motion.input
              key={i}
              ref={(el) => {
                refs.current[i] = el;
              }}
              value={v}
              onChange={(e) => onChange(i, e.target.value)}
              onKeyDown={(e) => onKeyDown(i, e)}
              onPaste={onPaste}
              onFocus={(e) => e.target.select()}
              inputMode="numeric"
              autoComplete={i === 0 ? "one-time-code" : "off"}
              maxLength={1}
              aria-label={`Digit ${i + 1}`}
              animate={{ scale: v ? [1, 1.08, 1] : 1 }}
              transition={{ duration: 0.2 }}
              className="size-12 rounded-xl border border-zinc-200 bg-white text-center text-xl font-semibold text-zinc-900 outline-none transition focus:border-blue-600 focus:ring-4 focus:ring-blue-600/10"
            />
          ))}
        </div>
      );
    }
    
    export default function Demo() {
      const [status, setStatus] = useState<string | null>(null);
      return (
        <div className="text-center">
          <p className="text-sm font-medium text-zinc-900">Enter the code we sent to your phone</p>
          <p className="mb-5 mt-1 text-xs text-zinc-500">Tip: try pasting 123456</p>
          <OtpInput onComplete={(c) => setStatus(`Verified ${c}`)} />
          <p className="mt-4 h-5 text-sm text-emerald-600">{status}</p>
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.