Docs
Go back
Component by
OV
OblivionTSX
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>
)
}"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-inputDependencies
npm 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>
)
}import OtpInput from "@/components/oblivion/otp-input"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<OtpInput />
</div>
)
}