Web InnoventixFreeCode

OTP Input

Original · free

6-box one-time-code input with paste

byWeb InnoventixReact + Tailwind
inpotpinputs
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/inp-otp.json
inp-otp.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ChangeEvent,
  type ClipboardEvent,
  type FocusEvent,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/* ------------------------------------------------------------------ */
/* helpers                                                             */
/* ------------------------------------------------------------------ */

const EMPTY = " ";

type Tone = "default" | "error" | "success";

function toCells(value: string, length: number): string[] {
  return value.padEnd(length, EMPTY).slice(0, length).split("");
}

function digitsOf(value: string): string {
  return value.replace(/[^0-9]/g, "");
}

function boxClasses(tone: Tone, filled: boolean): string {
  const base =
    "peer h-14 w-11 rounded-xl border text-center text-2xl font-semibold tabular-nums caret-transparent outline-none transition-[color,background-color,border-color,box-shadow,transform] duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-offset-slate-900 sm:h-16 sm:w-12";

  if (tone === "error") {
    return `${base} border-rose-400 bg-rose-50/70 text-rose-700 focus-visible:border-rose-500 focus-visible:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10 dark:text-rose-200`;
  }
  if (tone === "success") {
    return `${base} border-emerald-400 bg-emerald-50/70 text-emerald-700 focus-visible:border-emerald-500 focus-visible:ring-emerald-500 dark:border-emerald-500/70 dark:bg-emerald-500/10 dark:text-emerald-200`;
  }
  if (filled) {
    return `${base} border-indigo-400 bg-indigo-50/70 text-slate-900 focus-visible:border-indigo-500 focus-visible:ring-indigo-500 dark:border-indigo-500/70 dark:bg-indigo-500/10 dark:text-slate-50`;
  }
  return `${base} border-slate-300 bg-white text-slate-900 hover:border-slate-400 focus-visible:border-indigo-500 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:border-slate-600`;
}

/* ------------------------------------------------------------------ */
/* inline icons                                                        */
/* ------------------------------------------------------------------ */

function EnvelopeIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
      <rect x="3" y="5" width="18" height="14" rx="2.5" />
      <path d="m3.5 7 7.4 5.3a2 2 0 0 0 2.2 0L20.5 7" />
    </svg>
  );
}

function ShieldIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
      <path d="M12 3 5 6v5.5c0 4.3 2.9 7.5 7 9 4.1-1.5 7-4.7 7-9V6l-7-3Z" />
      <path d="m9 12 2 2 4-4" />
    </svg>
  );
}

function EyeIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" aria-hidden="true">
      <path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
      <circle cx="12" cy="12" r="3" />
    </svg>
  );
}

function EyeOffIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" aria-hidden="true">
      <path d="M3 3l18 18" />
      <path d="M10.6 6.2A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a15.6 15.6 0 0 1-3 3.6" />
      <path d="M6.4 7.9A15.4 15.4 0 0 0 2.5 12S6 18.5 12 18.5a9.3 9.3 0 0 0 3.7-.8" />
      <path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
    </svg>
  );
}

function CheckDrawIcon({ reduce }: { reduce: boolean }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
      <path
        d="M20 6 9 17l-5-5"
        strokeDasharray={24}
        strokeDashoffset={reduce ? 0 : 24}
        className={reduce ? undefined : "otp6-check"}
      />
    </svg>
  );
}

function SpinnerIcon({ reduce }: { reduce: boolean }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" className={`h-4 w-4 ${reduce ? "" : "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>
  );
}

/* ------------------------------------------------------------------ */
/* OTP field                                                           */
/* ------------------------------------------------------------------ */

type OtpFieldProps = {
  length: number;
  value: string;
  onChange: (next: string) => void;
  onComplete?: (code: string) => void;
  mask?: boolean;
  reveal?: boolean;
  disabled?: boolean;
  tone?: Tone;
  shake?: boolean;
  onShakeEnd?: () => void;
  ariaLabel: string;
  describedById?: string;
  autoFocus?: boolean;
};

function OtpField({
  length,
  value,
  onChange,
  onComplete,
  mask = false,
  reveal = false,
  disabled = false,
  tone = "default",
  shake = false,
  onShakeEnd,
  ariaLabel,
  describedById,
  autoFocus = false,
}: OtpFieldProps) {
  const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
  const cells = toCells(value, length);

  const focusBox = useCallback((index: number) => {
    const el = inputsRef.current[index];
    if (el) {
      el.focus();
      el.select();
    }
  }, []);

  const commit = useCallback(
    (nextCells: string[]) => {
      const next = nextCells.join("");
      onChange(next);
      if (digitsOf(next).length === length) {
        onComplete?.(digitsOf(next));
      }
    },
    [length, onChange, onComplete]
  );

  const handleChange = (index: number, event: ChangeEvent<HTMLInputElement>) => {
    const typed = digitsOf(event.target.value);
    if (!typed) return;
    const nextCells = toCells(value, length);
    let cursor = index;
    for (const digit of typed) {
      if (cursor >= length) break;
      nextCells[cursor] = digit;
      cursor += 1;
    }
    commit(nextCells);
    focusBox(Math.min(cursor, length - 1));
  };

  const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
    const nextCells = toCells(value, length);
    switch (event.key) {
      case "Backspace": {
        event.preventDefault();
        if (nextCells[index] !== EMPTY) {
          nextCells[index] = EMPTY;
          commit(nextCells);
        } else if (index > 0) {
          nextCells[index - 1] = EMPTY;
          commit(nextCells);
          focusBox(index - 1);
        }
        break;
      }
      case "Delete": {
        event.preventDefault();
        nextCells[index] = EMPTY;
        commit(nextCells);
        break;
      }
      case "ArrowLeft": {
        event.preventDefault();
        if (index > 0) focusBox(index - 1);
        break;
      }
      case "ArrowRight": {
        event.preventDefault();
        if (index < length - 1) focusBox(index + 1);
        break;
      }
      case "Home": {
        event.preventDefault();
        focusBox(0);
        break;
      }
      case "End": {
        event.preventDefault();
        focusBox(length - 1);
        break;
      }
      default:
        break;
    }
  };

  const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
    event.preventDefault();
    const pasted = digitsOf(event.clipboardData.getData("text")).slice(0, length);
    if (!pasted) return;
    const nextCells = Array.from({ length }, (_, k) => pasted[k] ?? EMPTY);
    commit(nextCells);
    focusBox(Math.min(pasted.length, length - 1));
  };

  const handleGroupBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
      setFocusedIndex(null);
    }
  };

  return (
    <div
      role="group"
      aria-label={ariaLabel}
      aria-describedby={describedById}
      onBlur={handleGroupBlur}
      className={`flex items-center gap-2 sm:gap-3 ${shake ? "otp6-shake" : ""}`}
      onAnimationEnd={onShakeEnd}
    >
      {cells.map((char, index) => {
        const isEmpty = char === EMPTY;
        const showCaret = focusedIndex === index && isEmpty && !disabled;
        const display = isEmpty ? "" : char;
        const type = mask && !reveal ? "password" : "text";
        return (
          <div key={index} className="relative">
            <input
              ref={(el) => {
                inputsRef.current[index] = el;
              }}
              type={type}
              inputMode="numeric"
              autoComplete={index === 0 ? "one-time-code" : "off"}
              pattern="[0-9]*"
              maxLength={1}
              spellCheck={false}
              autoCapitalize="off"
              disabled={disabled}
              // eslint-disable-next-line jsx-a11y/no-autofocus
              autoFocus={autoFocus && index === 0}
              value={display}
              aria-label={`${ariaLabel}, digit ${index + 1} of ${length}`}
              aria-invalid={tone === "error"}
              className={boxClasses(tone, !isEmpty)}
              onChange={(event) => handleChange(index, event)}
              onKeyDown={(event) => handleKeyDown(index, event)}
              onPaste={handlePaste}
              onFocus={(event) => {
                setFocusedIndex(index);
                event.currentTarget.select();
              }}
            />
            {showCaret ? (
              <span
                aria-hidden="true"
                className="otp6-caret-el pointer-events-none absolute left-1/2 top-1/2 h-6 w-[2px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-indigo-500 dark:bg-indigo-400"
              />
            ) : null}
          </div>
        );
      })}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* showcase                                                            */
/* ------------------------------------------------------------------ */

const DEMO_CODE = "482913";
const DEMO_PIN = "7391";

type Status = "idle" | "verifying" | "success" | "error";

function formatCountdown(total: number): string {
  const minutes = Math.floor(total / 60);
  const seconds = String(total % 60).padStart(2, "0");
  return `${minutes}:${seconds}`;
}

export default function OneTimeCodeInput() {
  const prefersReduced = useReducedMotion();
  const reduce = Boolean(prefersReduced);

  const hintId = useId();
  const pinHintId = useId();

  /* --- email verification card --- */
  const [code, setCode] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [shake, setShake] = useState(false);
  const [seconds, setSeconds] = useState(29);
  const timerRef = useRef<number | null>(null);

  useEffect(() => {
    return () => {
      if (timerRef.current) window.clearTimeout(timerRef.current);
    };
  }, []);

  useEffect(() => {
    if (seconds <= 0) return;
    const id = window.setTimeout(() => setSeconds((s) => s - 1), 1000);
    return () => window.clearTimeout(id);
  }, [seconds]);

  const runVerify = useCallback(
    (candidate: string) => {
      if (candidate.length !== DEMO_CODE.length) return;
      setStatus("verifying");
      if (timerRef.current) window.clearTimeout(timerRef.current);
      timerRef.current = window.setTimeout(() => {
        if (candidate === DEMO_CODE) {
          setStatus("success");
        } else {
          setStatus("error");
          if (!reduce) setShake(true);
        }
      }, 850);
    },
    [reduce]
  );

  const handleCodeChange = (next: string) => {
    setCode(next);
    if (status === "error" || status === "success") setStatus("idle");
  };

  const resetCode = () => {
    setCode("");
    setStatus("idle");
  };

  const handleResend = () => {
    setSeconds(29);
    resetCode();
  };

  const codeReady = digitsOf(code).length === DEMO_CODE.length;
  const codeTone: Tone = status === "error" ? "error" : status === "success" ? "success" : "default";
  const codeLocked = status === "verifying" || status === "success";

  const liveMessage =
    status === "verifying"
      ? "Verifying your code."
      : status === "success"
        ? "Code verified. You are signed in."
        : status === "error"
          ? "That code is incorrect. Please try again."
          : "";

  /* --- transaction PIN card --- */
  const [pin, setPin] = useState("");
  const [reveal, setReveal] = useState(false);
  const [pinAuthorized, setPinAuthorized] = useState<null | boolean>(null);

  const handlePinChange = (next: string) => {
    setPin(next);
    if (pinAuthorized !== null) setPinAuthorized(null);
  };

  const handlePinComplete = (candidate: string) => {
    setPinAuthorized(candidate === DEMO_PIN);
  };

  const pinReady = digitsOf(pin).length === DEMO_PIN.length;
  const pinTone: Tone = pinAuthorized === false ? "error" : pinAuthorized ? "success" : "default";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-24">
      <style>{`
        @keyframes otp6-shake {
          10%, 90% { transform: translateX(-1px); }
          20%, 80% { transform: translateX(2px); }
          30%, 50%, 70% { transform: translateX(-4px); }
          40%, 60% { transform: translateX(4px); }
        }
        @keyframes otp6-caret {
          0%, 49% { opacity: 1; }
          50%, 100% { opacity: 0; }
        }
        @keyframes otp6-draw { to { stroke-dashoffset: 0; } }
        .otp6-shake { animation: otp6-shake 0.42s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
        .otp6-caret-el { animation: otp6-caret 1.1s steps(1) infinite; }
        .otp6-check { animation: otp6-draw 0.5s 0.05s ease forwards; }
        @media (prefers-reduced-motion: reduce) {
          .otp6-shake, .otp6-caret-el, .otp6-check { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
        <div className="absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-violet-300/25 blur-3xl dark:bg-violet-700/15" />
        <div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,rgba(15,23,42,0.06)_1px,transparent_0)] bg-[size:22px_22px] dark:bg-[radial-gradient(circle_at_1px_1px,rgba(148,163,184,0.08)_1px,transparent_0)]" />
      </div>

      <div className="relative mx-auto max-w-5xl">
        <header className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-widest text-indigo-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:text-indigo-300">
            One-time code
          </span>
          <h2 className="mt-5 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
            Confirm it&rsquo;s really you
          </h2>
          <p className="mx-auto mt-3 max-w-lg text-pretty text-sm leading-relaxed text-slate-600 dark:text-slate-400 sm:text-base">
            Accessible six-box verification with auto-advance, paste-anywhere, and full keyboard
            control. Try typing, or paste a code &mdash; it splits across every box.
          </p>
        </header>

        <div className="mt-12 grid gap-6 lg:grid-cols-5">
          {/* ---------- Email verification ---------- */}
          <div className="lg:col-span-3">
            <div className="h-full rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 sm:p-8">
              <div className="flex items-center gap-3">
                <span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
                  <EnvelopeIcon />
                </span>
                <div>
                  <h3 className="text-base font-semibold">Verify your email</h3>
                  <p className="text-sm text-slate-500 dark:text-slate-400">
                    Code sent to <span className="font-medium text-slate-700 dark:text-slate-200">sardar@webinnoventix.com</span>
                  </p>
                </div>
              </div>

              <div className="mt-7">
                <div className="mb-3 flex items-baseline justify-between">
                  <span id={hintId} className="text-sm font-medium text-slate-700 dark:text-slate-300">
                    Enter your 6-digit code
                  </span>
                  <span className="text-xs text-slate-400 dark:text-slate-500">
                    Demo code: <span className="font-mono font-semibold text-slate-500 dark:text-slate-400">482913</span>
                  </span>
                </div>

                <OtpField
                  length={6}
                  value={code}
                  onChange={handleCodeChange}
                  onComplete={runVerify}
                  tone={codeTone}
                  disabled={codeLocked}
                  shake={shake}
                  onShakeEnd={() => setShake(false)}
                  ariaLabel="Email verification code"
                  describedById={hintId}
                />

                <p aria-live="polite" className="sr-only">
                  {liveMessage}
                </p>

                {/* status region */}
                <div className="mt-5 min-h-[3.25rem]">
                  <AnimatePresence mode="wait" initial={false}>
                    {status === "verifying" ? (
                      <motion.div
                        key="verifying"
                        initial={reduce ? false : { opacity: 0, y: 6 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
                        transition={{ duration: reduce ? 0 : 0.2 }}
                        className="flex items-center gap-2 rounded-xl bg-slate-100 px-4 py-3 text-sm font-medium text-slate-600 dark:bg-slate-800/70 dark:text-slate-300"
                      >
                        <SpinnerIcon reduce={reduce} />
                        Verifying your code&hellip;
                      </motion.div>
                    ) : status === "success" ? (
                      <motion.div
                        key="success"
                        initial={reduce ? false : { opacity: 0, y: 6 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
                        transition={{ duration: reduce ? 0 : 0.2 }}
                        className="flex items-center gap-2 rounded-xl bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300"
                      >
                        <CheckDrawIcon reduce={reduce} />
                        Verified &mdash; welcome back, Sardar.
                      </motion.div>
                    ) : status === "error" ? (
                      <motion.div
                        key="error"
                        initial={reduce ? false : { opacity: 0, y: 6 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
                        transition={{ duration: reduce ? 0 : 0.2 }}
                        className="flex items-center gap-2 rounded-xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700 dark:bg-rose-500/10 dark:text-rose-300"
                      >
                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" className="h-5 w-5" aria-hidden="true">
                          <circle cx="12" cy="12" r="9" />
                          <path d="M12 7v6M12 16.5v.5" />
                        </svg>
                        That code doesn&rsquo;t match. Check the digits and try again.
                      </motion.div>
                    ) : null}
                  </AnimatePresence>
                </div>

                {/* actions */}
                <div className="mt-2 flex flex-wrap items-center gap-3">
                  <button
                    type="button"
                    onClick={() => runVerify(digitsOf(code))}
                    disabled={!codeReady || codeLocked}
                    className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-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:bg-slate-300 disabled:text-slate-500 dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-700 dark:disabled:text-slate-400"
                  >
                    {status === "success" ? "Continue" : "Verify code"}
                  </button>
                  <button
                    type="button"
                    onClick={resetCode}
                    className="inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                  >
                    Clear
                  </button>
                </div>

                <div className="mt-6 border-t border-slate-200 pt-4 text-sm dark:border-slate-800">
                  <span className="text-slate-500 dark:text-slate-400">Didn&rsquo;t get the email? </span>
                  {seconds > 0 ? (
                    <span className="font-medium text-slate-400 dark:text-slate-500">
                      Resend in {formatCountdown(seconds)}
                    </span>
                  ) : (
                    <button
                      type="button"
                      onClick={handleResend}
                      className="rounded font-semibold text-indigo-600 underline-offset-4 hover:underline 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-indigo-300 dark:focus-visible:ring-offset-slate-900"
                    >
                      Resend code
                    </button>
                  )}
                </div>
              </div>
            </div>
          </div>

          {/* ---------- Transaction PIN (masked) ---------- */}
          <div className="lg:col-span-2">
            <div className="h-full rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 sm:p-8">
              <div className="flex items-center gap-3">
                <span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300">
                  <ShieldIcon />
                </span>
                <div>
                  <h3 className="text-base font-semibold">Authorize payment</h3>
                  <p className="text-sm text-slate-500 dark:text-slate-400">Masked 4-digit PIN</p>
                </div>
              </div>

              <p id={pinHintId} className="mt-6 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                Enter your PIN to send{" "}
                <span className="font-semibold text-slate-800 dark:text-slate-200">$1,240.00</span> to Acme Studio.
              </p>

              <div className="mt-5 flex items-center justify-between">
                <span className="text-sm font-medium text-slate-700 dark:text-slate-300">Transaction PIN</span>
                <button
                  type="button"
                  onClick={() => setReveal((r) => !r)}
                  aria-pressed={reveal}
                  className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 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-400 dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
                >
                  {reveal ? <EyeOffIcon /> : <EyeIcon />}
                  {reveal ? "Hide" : "Show"}
                </button>
              </div>

              <div className="mt-3">
                <OtpField
                  length={4}
                  value={pin}
                  onChange={handlePinChange}
                  onComplete={handlePinComplete}
                  mask
                  reveal={reveal}
                  tone={pinTone}
                  ariaLabel="Transaction PIN"
                  describedById={pinHintId}
                />
              </div>

              <div className="mt-4 min-h-[1.5rem] text-sm" aria-live="polite">
                {pinAuthorized === true ? (
                  <span className="font-medium text-emerald-600 dark:text-emerald-400">Payment authorized.</span>
                ) : pinAuthorized === false ? (
                  <span className="font-medium text-rose-600 dark:text-rose-400">Incorrect PIN. Try 7391.</span>
                ) : (
                  <span className="text-slate-400 dark:text-slate-500">Demo PIN: 7391</span>
                )}
              </div>

              <button
                type="button"
                disabled={!pinReady || pinAuthorized === true}
                onClick={() => handlePinComplete(digitsOf(pin))}
                className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-emerald-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500 dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-700 dark:disabled:text-slate-400"
              >
                {pinAuthorized ? "Authorized" : "Authorize transfer"}
              </button>

              <p className="mt-4 flex items-center gap-1.5 text-xs text-slate-400 dark:text-slate-500">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} className="h-3.5 w-3.5" aria-hidden="true">
                  <rect x="5" y="11" width="14" height="9" rx="2" />
                  <path d="M8 11V8a4 4 0 0 1 8 0v3" />
                </svg>
                Never share your PIN. We&rsquo;ll never ask for it by phone.
              </p>
            </div>
          </div>
        </div>
      </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 →