Oblivion
Oblivion
Docs
Go back
Component by
OV
Oblivion
TSX
otp-input
"use client"

import { useRef, useState, KeyboardEvent, ClipboardEvent } from "react"
import { cn } from "@/lib/utils"

export const componentMeta = {
  name: "OTP Input",
  description: "A one-time password input with auto-advance, backspace navigation, and paste support.",
  props: {
    length:    { type: "number",                  default: "6",      description: "Number of OTP digits" },
    onComplete:{ type: "(code: string) => void",                     description: "Called when all digits are filled" },
    variant:   { type: "'default' | 'boxed' | 'underline'", default: '"boxed"', description: "Visual style" },
    disabled:  { type: "boolean",                 default: "false",  description: "Disables all inputs" },
  }
}

type Variant = "default" | "boxed" | "underline"

const INPUT_CLS: Record<Variant, string> = {
  default:   "border border-zinc-200 dark:border-zinc-700 rounded-xl bg-zinc-50 dark:bg-zinc-900/40 focus:border-zinc-500 dark:focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200 dark:focus:ring-zinc-700/60",
  boxed:     "border-2 border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-900 focus:border-indigo-500 dark:focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:focus:ring-indigo-900/40",
  underline: "border-b-2 border-zinc-200 dark:border-zinc-700 rounded-none bg-transparent focus:border-zinc-900 dark:focus:border-zinc-100",
}

export default function OTPInput({
  length     = 6,
  onComplete,
  variant    = "boxed",
  disabled   = false,
  className,
}: {
  length?:     number
  onComplete?: (code: string) => void
  variant?:    Variant
  disabled?:   boolean
  className?:  string
}) {
  const [values, setValues] = useState<string[]>(Array(length).fill(""))
  const refs   = useRef<(HTMLInputElement | null)[]>([])

  const update = (idx: number, val: string) => {
    const next = [...values]
    next[idx] = val
    setValues(next)
    if (val && idx < length - 1) refs.current[idx + 1]?.focus()
    if (next.every(Boolean)) onComplete?.(next.join(""))
  }

  const handleKey = (e: KeyboardEvent<HTMLInputElement>, idx: number) => {
    if (e.key === "Backspace") {
      if (values[idx]) {
        update(idx, "")
      } else if (idx > 0) {
        refs.current[idx - 1]?.focus()
        update(idx - 1, "")
      }
    }
    if (e.key === "ArrowLeft" && idx > 0)          refs.current[idx - 1]?.focus()
    if (e.key === "ArrowRight" && idx < length - 1) refs.current[idx + 1]?.focus()
  }

  const handlePaste = (e: ClipboardEvent<HTMLInputElement>, startIdx: number) => {
    e.preventDefault()
    const pasted = e.clipboardData.getData("text").replace(/D/g, "").slice(0, length - startIdx)
    const next = [...values]
    for (let i = 0; i < pasted.length; i++) next[startIdx + i] = pasted[i]
    setValues(next)
    const focusIdx = Math.min(startIdx + pasted.length, length - 1)
    refs.current[focusIdx]?.focus()
    if (next.every(Boolean)) onComplete?.(next.join(""))
  }

  return (
    <div className={cn("flex items-center gap-2", className)}>
      {Array.from({ length }).map((_, i) => (
        <input
          key={i}
          ref={(el) => { refs.current[i] = el }}
          type="text"
          inputMode="numeric"
          maxLength={1}
          value={values[i]}
          disabled={disabled}
          onChange={(e) => {
            const val = e.target.value.replace(/D/g, "").slice(-1)
            update(i, val)
          }}
          onKeyDown={(e) => handleKey(e, i)}
          onPaste={(e) => handlePaste(e, i)}
          onFocus={(e) => e.target.select()}
          className={cn(
            "w-11 h-12 text-center text-base font-bold text-zinc-900 dark:text-white",
            "outline-none transition-all duration-200 caret-transparent",
            "disabled:opacity-40 disabled:cursor-not-allowed",
            INPUT_CLS[variant]
          )}
        />
      ))}
    </div>
  )
}

export function OTPInputPreview() {
  const [code, setCode] = useState("")
  const [verified, setVerified] = useState(false)

  return (
    <div className="flex w-full flex-col items-center justify-center gap-8 bg-transparent px-8 py-12">
      <div className="text-center space-y-1.5">
        <p className="text-xs font-semibold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Two-factor auth</p>
        <h2 className="text-xl font-bold text-zinc-900 dark:text-white">Check your email</h2>
        <p className="text-sm text-zinc-500 dark:text-zinc-400">We sent a 6-digit code to your inbox</p>
      </div>

      <div className="space-y-6 flex flex-col items-center">
        <OTPInput
          length={6}
          variant="boxed"
          onComplete={(c) => { setCode(c); setVerified(c === "123456") }}
        />
        {verified && (
          <p className="text-sm font-semibold text-emerald-500 animate-in fade-in slide-in-from-bottom-2">
            ✓ Code verified!
          </p>
        )}
        {code.length === 6 && !verified && (
          <p className="text-sm text-red-500 animate-in fade-in slide-in-from-bottom-2">
            Invalid code. Try 123456.
          </p>
        )}
      </div>

      <div className="flex gap-4">
        <OTPInput length={4} variant="underline" />
      </div>
    </div>
  )
}
Save to favoritesCopy to Figma
React

OTP Input

A one-time password input with auto-advance, backspace navigation, and paste support.

Installation

$ npx @cosmoo/oblivion add otp-input
Dependenciesnpm install

Usage Example

tsx
import OtpInput from "@/components/oblivion/otp-input"

export default function Demo() {
  return (
    <div className="p-10 flex justify-center">
      <OtpInput />
    </div>
  )
}