"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>
);
}