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
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>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/otp-input.tsxcomponents/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> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.