Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react";
import { Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";

export type OtpStatus = "idle" | "verifying" | "success" | "error";

export interface InputOTPProps {
  length?: number;
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  /**
   * Called when every slot is filled. Return `false` (or a promise resolving to `false`)
   * to show the error state; `true` shows success. Returning nothing keeps it neutral.
   */
  onComplete?: (code: string) => void | boolean | Promise<boolean>;
  /** Show dots instead of characters (PIN entry). */
  mask?: boolean;
  pattern?: "numeric" | "alphanumeric";
  /** Visually split slots into groups of this size (0 = no separator). */
  groupSize?: number;
  label?: string;
  /** External error message; also turns slots red. */
  error?: string;
  errorMessage?: string;
  disabled?: boolean;
  autoFocus?: boolean;
  /** Clear the slots after a failed verification. */
  clearOnError?: boolean;
  size?: "md" | "lg";
  className?: string;
}

const RX = { numeric: /[0-9]/, alphanumeric: /[a-z0-9]/i };

export function InputOTP({
  length = 6,
  value: valueProp,
  defaultValue = "",
  onChange,
  onComplete,
  mask = false,
  pattern = "numeric",
  groupSize = 3,
  label = "Verification code",
  error,
  errorMessage = "That code didn’t work. Try again.",
  disabled = false,
  autoFocus = false,
  clearOnError = true,
  size = "lg",
  className,
}: InputOTPProps) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const shake = useAnimationControls();
  const [inner, setInner] = React.useState(defaultValue.slice(0, length));
  const value = (valueProp ?? inner).slice(0, length);
  const [focusIdx, setFocusIdx] = React.useState<number | null>(null);
  const [status, setStatus] = React.useState<OtpStatus>("idle");
  const refs = React.useRef<(HTMLInputElement | null)[]>([]);
  // latest length, readable synchronously inside focus handlers fired mid-event
  const lenRef = React.useRef(value.length);
  React.useEffect(() => {
    lenRef.current = value.length;
  }, [value.length]);
  const rx = RX[pattern];
  const chars = Array.from({ length }, (_, i) => value[i] ?? "");
  const shownError = error ?? (status === "error" ? errorMessage : undefined);
  const locked = disabled || status === "verifying" || status === "success";

  React.useEffect(() => {
    if (autoFocus) refs.current[0]?.focus();
  }, [autoFocus]);

  const focusAt = (i: number) => {
    const el = refs.current[Math.max(0, Math.min(length - 1, i))];
    el?.focus();
    el?.select();
  };

  const fail = async () => {
    setStatus("error");
    if (!reduce) await shake.start({ x: [0, -10, 10, -7, 7, -3, 3, 0], transition: { duration: 0.45 } });
    if (clearOnError) {
      commit("", false);
      focusAt(0);
    }
  };

  const commit = (next: string, allowComplete = true) => {
    lenRef.current = next.length;
    if (valueProp === undefined) setInner(next);
    onChange?.(next);
    if (status === "error" && next.length) setStatus("idle");
    if (allowComplete && next.length === length && onComplete) {
      const res = onComplete(next);
      if (res instanceof Promise) {
        setStatus("verifying");
        res.then((ok) => (ok ? setStatus("success") : fail())).catch(() => fail());
      } else if (res === true) setStatus("success");
      else if (res === false) void fail();
    }
  };

  /** Write characters starting at slot `start`, return the next slot index. */
  const writeFrom = (start: number, text: string) => {
    const clean = Array.from(text).filter((c) => rx.test(c));
    if (!clean.length) return start;
    const arr = chars.slice();
    let i = start;
    for (const c of clean) {
      if (i >= length) break;
      arr[i++] = pattern === "alphanumeric" ? c.toUpperCase() : c;
    }
    // keep the value contiguous (no holes)
    const firstHole = arr.findIndex((c) => !c);
    const next = (firstHole === -1 ? arr : arr.slice(0, firstHole)).join("");
    commit(next);
    return Math.min(i, length - 1);
  };

  const onKeyDown = (i: number) => (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (locked) return;
    switch (e.key) {
      case "Backspace": {
        e.preventDefault();
        if (chars[i]) {
          commit(value.slice(0, i) + value.slice(i + 1));
          if (i >= value.length - 1 && i > 0 && !chars[i + 1]) focusAt(i - 1);
        } else if (i > 0) {
          commit(value.slice(0, i - 1) + value.slice(i));
          focusAt(i - 1);
        }
        break;
      }
      case "Delete":
        e.preventDefault();
        commit(value.slice(0, i) + value.slice(i + 1));
        break;
      case "ArrowLeft":
        e.preventDefault();
        focusAt(i - 1);
        break;
      case "ArrowRight":
        e.preventDefault();
        if (i < value.length) focusAt(i + 1);
        break;
      case "Home":
        e.preventDefault();
        focusAt(0);
        break;
      case "End":
        e.preventDefault();
        focusAt(Math.min(value.length, length - 1));
        break;
      default:
        if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
          e.preventDefault();
          if (!rx.test(e.key)) return;
          const slot = Math.min(i, value.length); // never leave a gap
          const next = writeFrom(slot, e.key);
          focusAt(slot + 1 >= length ? length - 1 : next);
        }
    }
  };

  const errored = !!shownError;
  const tone = status === "success" ? "success" : errored ? "error" : "idle";
  const box = size === "lg" ? "h-13 w-10 text-2xl sm:h-14 sm:w-12" : "h-11 w-9 text-lg sm:w-10";

  return (
    <div className={cn("flex flex-col items-center gap-3", className)}>
      <span id={`${uid}-label`} className="sr-only">
        {label}
      </span>
      <motion.div
        role="group"
        aria-labelledby={`${uid}-label`}
        aria-describedby={shownError ? `${uid}-err` : undefined}
        animate={shake}
        className="flex items-center gap-1.5 sm:gap-2"
        onPaste={(e) => {
          if (locked) return;
          e.preventDefault();
          const text = e.clipboardData.getData("text");
          const start = Math.min(focusIdx ?? 0, value.length);
          const next = writeFrom(start, text);
          focusAt(next);
        }}
      >
        {chars.map((c, i) => {
          const isFocused = focusIdx === i;
          const showCaret = isFocused && !c && !locked;
          return (
            <React.Fragment key={i}>
              {groupSize > 0 && i > 0 && i % groupSize === 0 && <span aria-hidden className="mx-0.5 h-0.5 w-2.5 rounded-full bg-foreground/25 sm:mx-1" />}
              <motion.div
                className="relative"
                animate={tone === "success" && !reduce ? { y: [0, -6, 0] } : { y: 0 }}
                transition={tone === "success" ? { delay: i * 0.05, duration: 0.35 } : undefined}
              >
                <input
                  ref={(el) => {
                    refs.current[i] = el;
                  }}
                  aria-label={`${mask ? "PIN" : "Code"} character ${i + 1} of ${length}`}
                  aria-invalid={errored || undefined}
                  inputMode={pattern === "numeric" ? "numeric" : "text"}
                  autoComplete={i === 0 ? "one-time-code" : "off"}
                  type={mask ? "password" : "text"}
                  maxLength={length}
                  value={c}
                  disabled={disabled}
                  readOnly={locked && !disabled}
                  onKeyDown={onKeyDown(i)}
                  onChange={(e) => {
                    // mobile keyboards / SMS autofill land here
                    const v = e.target.value;
                    if (!v) return;
                    const next = writeFrom(Math.min(i, value.length), v.length > 1 && c ? v.replace(c, "") : v);
                    focusAt(next);
                  }}
                  onFocus={(e) => {
                    setFocusIdx(i);
                    if (i > lenRef.current) focusAt(lenRef.current);
                    else e.currentTarget.select();
                  }}
                  onBlur={() => setFocusIdx((f) => (f === i ? null : f))}
                  className={cn(
                    box,
                    "rounded-xl border-2 bg-background text-center font-semibold tabular-nums text-transparent caret-transparent outline-none transition-[border-color,box-shadow,background-color] duration-200 selection:bg-transparent",
                    "disabled:cursor-not-allowed disabled:opacity-50",
                    tone === "idle" && (isFocused ? "border-primary ring-4 ring-primary/15" : c ? "border-foreground/30" : "border-foreground/15 hover:border-foreground/30"),
                    tone === "error" && "border-destructive bg-destructive/5 ring-destructive/15",
                    tone === "error" && isFocused && "ring-4",
                    tone === "success" && "border-emerald-500 bg-emerald-500/10",
                  )}
                />
                {/* visual character layer */}
                <span aria-hidden className="pointer-events-none absolute inset-0 grid place-items-center">
                  <AnimatePresence initial={false}>
                    {c && (
                      <motion.span
                        key={c + i}
                        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, y: 8 }}
                        animate={{ opacity: 1, scale: 1, y: 0 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, transition: { duration: 0.1 } }}
                        transition={{ type: "spring", stiffness: 700, damping: 26 }}
                        className={cn(
                          "absolute font-semibold tabular-nums",
                          size === "lg" ? "text-2xl" : "text-lg",
                          tone === "error" ? "text-destructive" : tone === "success" ? "text-emerald-600 dark:text-emerald-400" : "text-foreground",
                        )}
                      >
                        {mask ? <span className={cn("block rounded-full bg-current", size === "lg" ? "size-3" : "size-2.5")} /> : c}
                      </motion.span>
                    )}
                  </AnimatePresence>
                  {showCaret && (
                    <motion.span
                      className={cn("absolute w-0.5 rounded-full bg-primary", size === "lg" ? "h-6" : "h-5")}
                      animate={reduce ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}
                      transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}
                    />
                  )}
                </span>
              </motion.div>
            </React.Fragment>
          );
        })}
      </motion.div>

      <div className="flex min-h-5 items-center text-sm" aria-live="polite">
        <AnimatePresence mode="wait" initial={false}>
          {status === "verifying" ? (
            <motion.span key="v" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-center gap-1.5 text-muted-foreground">
              <Loader2 className="size-3.5 animate-spin" aria-hidden /> Verifying…
            </motion.span>
          ) : status === "success" ? (
            <motion.span key="s" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-center gap-1.5 font-medium text-emerald-600 dark:text-emerald-400">
              <Check className="size-4" aria-hidden /> Verified
            </motion.span>
          ) : shownError ? (
            <motion.span key="e" id={`${uid}-err`} role="alert" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="text-destructive">
              {shownError}
            </motion.span>
          ) : null}
        </AnimatePresence>
      </div>
    </div>
  );
}

More in Forms & Inputs

View all →