OTP Verify Form
Original · freeOTP verification form
byWeb InnoventixReact + Tailwind
formotpverifyforms
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/form-otp-verify.jsonform-otp-verify.tsx
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ClipboardEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Status = "idle" | "verifying" | "success" | "error";
type Channel = "sms" | "email";
const OTP_LENGTH = 6;
const CORRECT_CODE = "314159";
const RESEND_SECONDS = 30;
const CHANNELS: ReadonlyArray<{ id: Channel; label: string; dest: string; noun: string }> = [
{ id: "sms", label: "Text message", dest: "+1 (415) ••• ••82", noun: "phone" },
{ id: "email", label: "Email", dest: "s•••••n@webinnoventix.com", noun: "inbox" },
];
function formatTime(total: number): string {
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function inputClass(filled: boolean, status: Status): string {
const base =
"h-14 w-full rounded-xl border bg-white text-center text-2xl font-semibold text-slate-900 caret-indigo-600 shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60 dark:bg-slate-950 dark:text-slate-50 dark:focus-visible:ring-offset-slate-900";
let border: string;
if (status === "error") {
border = "border-rose-400 dark:border-rose-500/70";
} else if (status === "success") {
border =
"border-emerald-400 bg-emerald-50 text-emerald-700 dark:border-emerald-500/60 dark:bg-emerald-500/10 dark:text-emerald-300";
} else if (filled) {
border = "border-indigo-400 dark:border-indigo-500/70";
} else {
border =
"border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600";
}
return `${base} ${border}`;
}
function ChannelIcon({ id }: { id: Channel }) {
if (id === "sms") {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="h-4 w-4" aria-hidden="true">
<path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 9.5 9.5 0 0 1-4-.9L3 20l1-3.5A8.5 8.5 0 1 1 21 11.5Z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="h-4 w-4" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m4 7 8 6 8-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export default function OtpVerifyForm() {
const reduced = useReducedMotion();
const uid = useId();
const titleId = `${uid}-title`;
const descId = `${uid}-desc`;
const hintId = `${uid}-hint`;
const [digits, setDigits] = useState<string[]>(() => Array(OTP_LENGTH).fill(""));
const [status, setStatus] = useState<Status>("idle");
const [message, setMessage] = useState<string>("");
const [secondsLeft, setSecondsLeft] = useState<number>(RESEND_SECONDS);
const [channel, setChannel] = useState<Channel>("sms");
const [shake, setShake] = useState<boolean>(false);
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const channelsRef = useRef<Array<HTMLButtonElement | null>>([]);
const code = digits.join("");
const isComplete = code.length === OTP_LENGTH;
const locked = status === "verifying" || status === "success";
const active = CHANNELS.find((c) => c.id === channel) ?? CHANNELS[0];
useEffect(() => {
if (secondsLeft <= 0) return;
const id = window.setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => window.clearTimeout(id);
}, [secondsLeft]);
function focusIndex(index: number) {
const el = inputsRef.current[index];
if (el) {
el.focus();
el.select();
}
}
function clearError() {
if (status === "error") setStatus("idle");
setMessage("");
}
function handleChange(index: number, raw: string) {
const value = raw.replace(/\D/g, "");
if (!value) {
setDigits((prev) => {
const next = [...prev];
next[index] = "";
return next;
});
clearError();
return;
}
clearError();
setDigits((prev) => {
const next = [...prev];
let cursor = index;
for (const ch of value.split("")) {
if (cursor >= OTP_LENGTH) break;
next[cursor] = ch;
cursor += 1;
}
return next;
});
focusIndex(Math.min(index + value.length, OTP_LENGTH - 1));
}
function handleKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Backspace") {
e.preventDefault();
clearError();
setDigits((prev) => {
const next = [...prev];
if (next[index]) {
next[index] = "";
} else if (index > 0) {
next[index - 1] = "";
}
return next;
});
if (!digits[index] && index > 0) focusIndex(index - 1);
} else if (e.key === "Delete") {
e.preventDefault();
clearError();
setDigits((prev) => {
const next = [...prev];
next[index] = "";
return next;
});
} else if (e.key === "ArrowLeft" && index > 0) {
e.preventDefault();
focusIndex(index - 1);
} else if (e.key === "ArrowRight" && index < OTP_LENGTH - 1) {
e.preventDefault();
focusIndex(index + 1);
}
}
function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
e.preventDefault();
const text = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, OTP_LENGTH);
if (!text) return;
clearError();
const chars = text.split("");
setDigits(() => {
const next: string[] = Array(OTP_LENGTH).fill("");
chars.forEach((ch, i) => {
next[i] = ch;
});
return next;
});
focusIndex(Math.min(chars.length, OTP_LENGTH - 1));
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!isComplete || locked) return;
setStatus("verifying");
setMessage("Checking your code…");
window.setTimeout(() => {
if (code === CORRECT_CODE) {
setStatus("success");
setMessage("Identity confirmed — taking you to your dashboard.");
} else {
setStatus("error");
setMessage("That code doesn't match. Please re-enter the 6 digits.");
if (!reduced) setShake(true);
setDigits(Array(OTP_LENGTH).fill(""));
focusIndex(0);
}
}, 1100);
}
function handleResend() {
if (secondsLeft > 0 || locked) return;
setSecondsLeft(RESEND_SECONDS);
setDigits(Array(OTP_LENGTH).fill(""));
setStatus("idle");
setMessage(`A fresh code is on its way to your ${active.noun}.`);
focusIndex(0);
}
function selectChannel(id: Channel) {
if (id === channel || locked) return;
const nextChannel = CHANNELS.find((c) => c.id === id) ?? CHANNELS[0];
setChannel(id);
setDigits(Array(OTP_LENGTH).fill(""));
setStatus("idle");
setSecondsLeft(RESEND_SECONDS);
setMessage(`New code sent to your ${nextChannel.noun}.`);
}
function handleChannelKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
let target: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
target = (index + 1) % CHANNELS.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
target = (index - 1 + CHANNELS.length) % CHANNELS.length;
}
if (target === null) return;
e.preventDefault();
selectChannel(CHANNELS[target].id);
channelsRef.current[target]?.focus();
}
const buttonClass =
status === "success"
? "bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500 active:bg-emerald-700"
: "bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:ring-indigo-500 active:bg-indigo-700 disabled:bg-slate-300 disabled:text-slate-500 dark:disabled:bg-slate-700 dark:disabled:text-slate-400";
return (
<section
aria-labelledby={titleId}
className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950"
>
<style>{`
@keyframes otpv-shake{10%,90%{transform:translateX(-1px)}20%,80%{transform:translateX(2px)}30%,50%,70%{transform:translateX(-4px)}40%,60%{transform:translateX(4px)}}
@keyframes otpv-drift{0%,100%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-16px,0)}}
@keyframes otpv-check{from{stroke-dashoffset:26}to{stroke-dashoffset:0}}
.otpv-blob-a{animation:otpv-drift 9s ease-in-out infinite}
.otpv-blob-b{animation:otpv-drift 11s ease-in-out infinite reverse}
.otpv-check-path{stroke-dasharray:26;stroke-dashoffset:26;animation:otpv-check .5s ease forwards}
@media (prefers-reduced-motion: reduce){
.otpv-blob-a,.otpv-blob-b{animation:none!important}
.otpv-check-path{animation:none!important;stroke-dashoffset:0!important}
}
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="otpv-blob-a absolute -left-16 top-4 h-56 w-56 rounded-full bg-indigo-400/25 blur-3xl dark:bg-indigo-600/20" />
<div className="otpv-blob-b absolute -right-12 bottom-0 h-64 w-64 rounded-full bg-violet-400/25 blur-3xl dark:bg-violet-600/20" />
</div>
<motion.div
initial={reduced ? false : { opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="relative mx-auto w-full max-w-md"
>
<div className="rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-900/5 backdrop-blur sm:p-8 dark:border-slate-800 dark:bg-slate-900/80 dark:shadow-black/40">
<div className="flex flex-col items-center text-center">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/30">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="h-6 w-6" aria-hidden="true">
<path d="M12 3 5 6v5c0 4.2 2.8 7.9 7 9 4.2-1.1 7-4.8 7-9V6l-7-3Z" strokeLinecap="round" strokeLinejoin="round" />
<path d="m9 12 2 2 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Two-step verification
</p>
<h2 id={titleId} className="mt-2 text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
Enter your security code
</h2>
<p id={descId} className="mt-2 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
We sent a 6-digit code to{" "}
<span className="font-medium text-slate-700 dark:text-slate-200">{active.dest}</span>. It expires in 10 minutes.
</p>
</div>
<div role="radiogroup" aria-label="Where to send the code" className="mt-6 flex justify-center">
<div className="inline-flex rounded-full bg-slate-100 p-1 dark:bg-slate-800">
{CHANNELS.map((c, i) => {
const selected = c.id === channel;
return (
<button
key={c.id}
ref={(el) => {
channelsRef.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
disabled={locked}
onClick={() => selectChannel(c.id)}
onKeyDown={(e) => handleChannelKeyDown(e, i)}
className={`inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-offset-slate-800 ${
selected
? "bg-white text-indigo-700 shadow-sm dark:bg-slate-950 dark:text-indigo-300"
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
<ChannelIcon id={c.id} />
{c.label}
</button>
);
})}
</div>
</div>
<form onSubmit={handleSubmit} noValidate className="mt-6">
<div
role="group"
aria-label={`One-time passcode, ${OTP_LENGTH} digits`}
aria-describedby={hintId}
onAnimationEnd={() => setShake(false)}
style={shake ? { animation: "otpv-shake .42s cubic-bezier(.36,.07,.19,.97) both" } : undefined}
className="grid grid-cols-6 gap-2 sm:gap-3"
>
{digits.map((digit, i) => (
<input
key={i}
ref={(el) => {
inputsRef.current[i] = el;
}}
type="text"
inputMode="numeric"
autoComplete={i === 0 ? "one-time-code" : "off"}
autoCapitalize="off"
spellCheck={false}
pattern="\d*"
maxLength={1}
value={digit}
disabled={locked}
aria-label={`Digit ${i + 1} of ${OTP_LENGTH}`}
aria-invalid={status === "error"}
onChange={(e) => handleChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={handlePaste}
onFocus={(e) => e.target.select()}
className={inputClass(digit !== "", status)}
/>
))}
</div>
<p id={hintId} className="mt-3 text-center text-xs text-slate-400 dark:text-slate-500">
Type the digits or paste the whole code — we'll fill the boxes for you.
</p>
<div className="mt-3 min-h-[1.5rem]" role="status" aria-live="polite">
{message && (
<p
className={`flex items-center justify-center gap-1.5 text-center text-sm ${
status === "success"
? "text-emerald-600 dark:text-emerald-400"
: status === "error"
? "text-rose-600 dark:text-rose-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
{status === "success" && (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} className="h-4 w-4 shrink-0" aria-hidden="true">
<path className="otpv-check-path" d="m5 13 4 4 10-11" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
{status === "error" && (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4 shrink-0" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4m0 4h.01" strokeLinecap="round" />
</svg>
)}
<span>{message}</span>
</p>
)}
</div>
<button
type="submit"
disabled={!isComplete || locked}
className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed dark:focus-visible:ring-offset-slate-900 ${buttonClass}`}
>
{status === "verifying" && (
<svg viewBox="0 0 24 24" fill="none" className={`h-4 w-4 ${reduced ? "" : "animate-spin"}`} aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={3} className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth={3} strokeLinecap="round" />
</svg>
)}
{status === "verifying" ? "Verifying…" : status === "success" ? "Verified" : "Verify code"}
</button>
</form>
<div className="mt-6 flex flex-col items-center gap-1 text-sm">
<span className="text-slate-500 dark:text-slate-400">Didn't receive the code?</span>
<button
type="button"
onClick={handleResend}
disabled={secondsLeft > 0 || locked}
className="rounded font-semibold text-indigo-600 transition hover:text-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:text-slate-400 disabled:hover:text-slate-400 dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-900 dark:disabled:text-slate-500"
>
{secondsLeft > 0 ? `Resend available in ${formatTime(secondsLeft)}` : "Resend code"}
</button>
</div>
<div className="mt-6 border-t border-slate-200 pt-5 text-center text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
Entered the wrong details?{" "}
<button
type="button"
className="rounded font-medium text-slate-700 underline decoration-slate-300 underline-offset-2 transition hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-200 dark:decoration-slate-600 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
Change your {active.noun === "phone" ? "number" : "email"}
</button>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Demo — enter{" "}
<span className="rounded bg-slate-200/70 px-1.5 py-0.5 font-mono font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
314159
</span>{" "}
to see the verified state.
</p>
</motion.div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

Booking Form
Originalbooking form with date and slots

