Web InnoventixFreeCode

OTP Verify Form

Original · free

OTP verification form

byWeb InnoventixReact + Tailwind
formotpverifyforms
Open

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.json
form-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 quote

Similar components

Browse all →