"use client";
import * as React from "react";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
export interface OtpInputProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue"> {
/** Number of characters in the code. */
length?: number;
/** Controlled value. */
value?: string;
/** Initial value when uncontrolled. */
defaultValue?: string;
/** Called with the current code on every change. */
onChange?: (value: string) => void;
/** Called once every slot is filled. */
onComplete?: (value: string) => void;
/** numeric: digits only (numeric keypad) · alphanumeric: A–Z and 0–9, upper-cased. */
mode?: "numeric" | "alphanumeric";
/** Error state. Truthy values mark the inputs invalid and shake the row; a node is also shown. */
error?: React.ReactNode;
/** Group label, announced by screen readers. */
label?: string;
/** Visually hide the label (it stays available to assistive tech). */
hideLabel?: boolean;
/** Render dots instead of characters. */
mask?: boolean;
/** Show a divider in the middle of even-length codes. */
separator?: boolean;
size?: "md" | "lg";
/** Focus colour (any CSS colour). */
accent?: string;
disabled?: boolean;
/** Focus the first slot on mount. */
autoFocus?: boolean;
/** When set, a hidden input submits the code with native forms. */
name?: string;
}
const PATTERNS = {
numeric: /[^0-9]/g,
alphanumeric: /[^A-Z0-9]/g,
};
const sanitize = (text: string, mode: "numeric" | "alphanumeric") => text.toUpperCase().replace(PATTERNS[mode], "");
const toSlots = (text: string, length: number) => Array.from({ length }, (_, i) => text[i] ?? "");
const SIZES = {
md: { slot: "h-14 w-11 rounded-xl text-[22px]", gap: "gap-2", caret: "h-6" },
lg: { slot: "h-16 w-[52px] rounded-2xl text-[26px]", gap: "gap-2.5", caret: "h-7" },
};
/**
* One-time-code entry as a group of single-character inputs. Handles typing, paste and
* SMS autofill (distributing characters across slots), arrow / Home / End navigation and
* Backspace that clears then moves back. Characters pop in, the caret blinks, and errors shake.
*/
export function OtpInput({
length = 6,
value,
defaultValue = "",
onChange,
onComplete,
mode = "numeric",
error,
label = "Verification code",
hideLabel = false,
mask = false,
separator = true,
size = "md",
accent = "#FF7448",
disabled = false,
autoFocus = false,
name,
className = "",
style,
...props
}: OtpInputProps) {
const count = Math.max(1, Math.min(12, Math.round(length)));
const reduceMotion = useReducedMotion();
const uid = React.useId();
const labelId = `${uid}-label`;
const errorId = `${uid}-error`;
const inputs = React.useRef<(HTMLInputElement | null)[]>([]);
const [scope, animate] = useAnimate<HTMLDivElement>();
// Slots are kept as an array so a cleared middle slot keeps its position.
const [chars, setChars] = React.useState(() => toSlots(sanitize(value ?? defaultValue, mode), count));
// Re-sync when a controlled value changes from outside (store-previous-prop pattern).
const [syncedValue, setSyncedValue] = React.useState(value);
if (value !== undefined && value !== syncedValue) {
setSyncedValue(value);
if (value !== chars.join("")) setChars(toSlots(sanitize(value, mode), count));
}
const slots = Array.from({ length: count }, (_, i) => chars[i] ?? "");
const [focused, setFocused] = React.useState<number | null>(null);
const invalid = Boolean(error);
const errorText = invalid && error !== true ? error : null;
const firstEmpty = slots.findIndex((c) => !c);
// Shake the row whenever the field becomes invalid.
React.useEffect(() => {
if (!invalid || reduceMotion || !scope.current) return;
animate(scope.current, { x: [0, -9, 8, -6, 4, -2, 0] }, { duration: 0.42, ease: "easeOut" });
}, [invalid, reduceMotion, animate, scope]);
const focusSlot = (i: number) => {
const el = inputs.current[Math.max(0, Math.min(count - 1, i))];
if (el) {
el.focus();
el.select();
}
};
const commit = (next: string[]) => {
setChars(next);
const code = next.join("");
onChange?.(code);
if (next.every(Boolean) && next.join("") !== slots.join("")) onComplete?.(code);
};
/** Writes `text` into the slots starting at `start` and moves focus after it. */
const distribute = (text: string, start: number) => {
const clean = sanitize(text, mode);
if (!clean) return;
const from = clean.length >= count ? 0 : start;
const next = [...slots];
for (let k = 0; k < clean.length && from + k < count; k++) next[from + k] = clean[k];
commit(next);
focusSlot(Math.min(from + clean.length, count - 1));
};
const handleChange = (i: number, event: React.ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
let clean = sanitize(raw, mode);
// One extra character next to the existing one: the caret wasn't selecting it, so keep
// only what was typed. Longer inserts (autofill, IME) are distributed below.
if (slots[i] && clean.length === 2 && clean.includes(slots[i])) clean = clean.replace(slots[i], "");
if (!clean) {
if (!raw) {
const next = [...slots];
next[i] = "";
commit(next);
}
return;
}
if (clean.length > 1) {
// Autofill (e.g. iOS one-time-code) or a multi-character insert.
distribute(clean, i);
return;
}
const next = [...slots];
next[i] = clean;
commit(next);
if (i < count - 1) focusSlot(i + 1);
};
const handleKeyDown = (i: number, event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case "Backspace": {
event.preventDefault();
const next = [...slots];
if (next[i]) {
next[i] = "";
commit(next);
} else if (i > 0) {
next[i - 1] = "";
commit(next);
focusSlot(i - 1);
}
break;
}
case "Delete": {
event.preventDefault();
if (slots[i]) {
const next = [...slots];
next[i] = "";
commit(next);
}
break;
}
case "ArrowLeft":
event.preventDefault();
focusSlot(i - 1);
break;
case "ArrowRight":
event.preventDefault();
focusSlot(i + 1);
break;
case "Home":
event.preventDefault();
focusSlot(0);
break;
case "End":
event.preventDefault();
focusSlot(firstEmpty === -1 ? count - 1 : firstEmpty);
break;
default:
// Re-typing the same character produces no change event; still advance.
if (
event.key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
slots[i] &&
sanitize(event.key, mode) === slots[i] &&
i < count - 1
) {
event.preventDefault();
focusSlot(i + 1);
}
}
};
const half = separator && count >= 4 && count % 2 === 0 ? count / 2 : -1;
const dims = SIZES[size];
const code = slots.join("");
return (
<div
role="group"
aria-labelledby={labelId}
className={`inline-flex flex-col items-start ${className}`}
style={{ ["--otp-accent" as string]: accent, ...style }}
{...props}
>
<span
id={labelId}
className={hideLabel ? "sr-only" : "mb-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400"}
>
{label}
</span>
<div ref={scope} className={`flex items-center ${dims.gap}`}>
{slots.map((char, i) => {
const isFocused = focused === i;
return (
<React.Fragment key={i}>
{i === half && (
<span aria-hidden="true" className="mx-1 h-px w-3 rounded-full bg-zinc-900/25 dark:bg-white/25" />
)}
<div
className={`relative grid shrink-0 place-items-center border bg-white transition-[border-color,box-shadow,background-color] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${dims.slot} ${
invalid
? "border-rose-500/80 dark:border-rose-400/80"
: isFocused
? "border-[var(--otp-accent)] shadow-[0_0_0_3px_color-mix(in_srgb,var(--otp-accent)_22%,transparent)]"
: char
? "border-zinc-900/30 dark:border-white/30"
: "border-zinc-900/12 dark:border-white/12"
} ${disabled ? "opacity-50" : ""}`}
>
<input
ref={(el) => {
inputs.current[i] = el;
}}
type={mask ? "password" : "text"}
inputMode={mode === "numeric" ? "numeric" : "text"}
pattern={mode === "numeric" ? "[0-9]*" : undefined}
autoComplete={i === 0 ? "one-time-code" : "off"}
autoCapitalize={mode === "alphanumeric" ? "characters" : "off"}
autoCorrect="off"
spellCheck={false}
enterKeyHint={i === count - 1 ? "done" : "next"}
aria-label={`${mode === "numeric" ? "Digit" : "Character"} ${i + 1} of ${count}`}
aria-invalid={invalid || undefined}
aria-describedby={errorText ? errorId : undefined}
disabled={disabled}
autoFocus={autoFocus && i === 0}
value={char}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={(e) => {
e.preventDefault();
distribute(e.clipboardData.getData("text"), i);
}}
onFocus={(e) => {
setFocused(i);
e.currentTarget.select();
}}
onBlur={() => setFocused((f) => (f === i ? null : f))}
onMouseDown={(e) => {
// Clicking past the first empty slot lands on the first empty slot.
if (firstEmpty !== -1 && i > firstEmpty) {
e.preventDefault();
focusSlot(firstEmpty);
}
}}
// The real input is invisible; the glyph below is its visual twin.
className="absolute inset-0 h-full w-full cursor-text rounded-[inherit] bg-transparent text-center text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed"
/>
<AnimatePresence initial={false}>
{char ? (
<motion.span
key={char + i}
aria-hidden="true"
className="pointer-events-none font-medium tabular-nums tracking-tight text-zinc-950 dark:text-white"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.7 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 26, mass: 0.6 }}
style={{ gridArea: "1 / 1" }}
>
{mask ? <span className="block size-2.5 rounded-full bg-current" /> : char}
</motion.span>
) : isFocused ? (
<motion.span
key="caret"
aria-hidden="true"
className={`pointer-events-none w-[2px] rounded-full bg-[var(--otp-accent)] ${dims.caret}`}
initial={{ opacity: 1 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}
transition={reduceMotion ? { duration: 0 } : { duration: 1, times: [0, 0.5, 0.55, 1], repeat: Infinity }}
style={{ gridArea: "1 / 1" }}
/>
) : null}
</AnimatePresence>
</div>
</React.Fragment>
);
})}
</div>
{/* w-0 + min-w-full: the message wraps to the row's width instead of widening the group. */}
<div aria-live="polite" className="w-0 min-w-full">
{errorText && (
<p id={errorId} className="mt-2.5 text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400">
{errorText}
</p>
)}
</div>
{name && <input type="hidden" name={name} value={code} />}
</div>
);
}