## OTP Input — Input Auto advance, paste, error recovery. Docs: https://www.interior.dev/docs/otp-input Reference: https://www.interior.dev/reference/otp-input License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/otp-input.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { OtpInput } from "@/components/interior/otp-input"; export function VerifyStep({ challengeId }: { challengeId: string }) { const [status, setStatus] = useState<"idle" | "checking" | "rejected">("idle"); async function submit(code: string) { setStatus("checking"); const res = await fetch("/api/verify", { method: "POST", body: JSON.stringify({ challengeId, code }), }); setStatus(res.ok ? "idle" : "rejected"); } return ( setStatus((s) => (s === "rejected" ? "idle" : s))} onComplete={submit} /> ); } ``` ### Props - `length` (`number`) — default: `6` Number of cells. The group reserves its full width on first paint. - `mode` (`"numeric" | "alphanumeric"`) — default: `"numeric"` Which characters survive typing and pasting. Sets inputMode so phones open the right keyboard. - `defaultValue` (`string`) — default: `""` Seed characters, filtered by mode and truncated to length. The cells own the value after mount. - `onChange` (`(value: string) => void`) Fires on every accepted edit, including a paste that fills several cells at once. - `onComplete` (`(value: string) => void`) Fires the moment every cell holds a character, and again on each later edit of a full code. - `error` (`boolean`) — default: `false` Marks every cell aria-invalid and shakes the group once on the false to true edge. The characters are kept. - `errorMessage` (`string`) — default: `""` Shown in the status line and announced once through a polite live region. - `hint` (`string`) — default: `""` Occupies the same grid cell as the error message, so the two never move each other. - `label` (`string`) — default: `"Verification code"` Names the group and each cell, as "character 3 of 6". - `disabled` (`boolean`) — default: `false` Disables every cell and drops the focus indicator. - `autoFocus` (`boolean`) — default: `false` Focuses the first cell after mount rather than during render. - `focusOnError` (`boolean`) — default: `true` On rejection, returns focus to the first cell with its character selected so retyping overwrites. - `className` (`string`) — default: `""` Appended last on the outer wrapper. ### Behavior notes - A pasted code fills every cell from one event: paste is intercepted, filtered to the allowed alphabet, and distributed from cell zero whenever the clipboard holds a full-length code, so pasting into the wrong cell is not a failure state. - Autofilled codes arrive as a single multi-character input event and are distributed the same way, because only the first cell claims autocomplete one-time-code and no cell sets maxLength. - Rejection never destroys work: the characters stay where they are, focus returns to the first cell with its content selected, and the shake plays once on the false to true edge instead of on every render while the error is up. - The cell array is the source of truth, so clearing a digit in the middle leaves a hole instead of sliding the digits after it one place left. - The hint and the error message share one grid cell and only opacity moves, so a wrapped two-line error cannot push the submit button down the page. - Screen readers get the message once from a polite live region and each cell announces its own position; under prefers-reduced-motion the shake, the sliding focus mark and the character entrance are skipped while the value, the invalid border and the status line still arrive. ### Source (`components/interior/otp-input.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useImperativeHandle, useRef, useState, type ChangeEvent, type ClipboardEvent, type FocusEvent, type KeyboardEvent, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const EASE = [0.23, 1, 0.32, 1] as const; export type OtpMode = "numeric" | "alphanumeric"; const ALLOW: Record = { numeric: /^[0-9]$/, alphanumeric: /^[0-9a-zA-Z]$/, }; export type UseOtpInputOptions = { length?: number; mode?: OtpMode; defaultValue?: string; disabled?: boolean; onChange?: (value: string) => void; onComplete?: (value: string) => void; }; export type OtpCellProps = { ref: (el: HTMLInputElement | null) => void; value: string; disabled: boolean; type: "text"; inputMode: "numeric" | "text"; autoComplete: string; autoCorrect: "off"; autoCapitalize: "off"; spellCheck: false; onChange: (e: ChangeEvent) => void; onKeyDown: (e: KeyboardEvent) => void; onPaste: (e: ClipboardEvent) => void; onFocus: (e: FocusEvent) => void; onBlur: (e: FocusEvent) => void; }; export type UseOtpInputReturn = { chars: string[]; value: string; length: number; complete: boolean; focusedIndex: number; getCellProps: (index: number) => OtpCellProps; focusAt: (index: number) => void; clear: () => void; }; export function useOtpInput({ length = 6, mode = "numeric", defaultValue = "", disabled = false, onChange, onComplete, }: UseOtpInputOptions = {}): UseOtpInputReturn { const allow = ALLOW[mode]; const keep = useCallback( (text: string) => text .split("") .filter((c) => allow.test(c)) .join(""), [allow], ); const [chars, setChars] = useState(() => { const seed = defaultValue .split("") .filter((c) => ALLOW[mode].test(c)) .slice(0, length); return Array.from({ length }, (_, i) => seed[i] ?? ""); }); const [focusedIndex, setFocusedIndex] = useState(-1); const charsRef = useRef(chars); charsRef.current = chars; const refs = useRef<(HTMLInputElement | null)[]>([]); const changed = useRef(onChange); changed.current = onChange; const completed = useRef(onComplete); completed.current = onComplete; useEffect(() => { setChars((prev) => prev.length === length ? prev : Array.from({ length }, (_, i) => prev[i] ?? ""), ); refs.current.length = length; }, [length]); const commit = useCallback((next: string[]) => { charsRef.current = next; setChars(next); const value = next.join(""); changed.current?.(value); if (next.length > 0 && next.every((c) => c !== "")) completed.current?.(value); }, []); const focusAt = useCallback( (index: number) => { const el = refs.current[Math.max(0, Math.min(length - 1, index))]; if (!el) return; el.focus(); el.select(); }, [length], ); const fillFrom = useCallback( (index: number, text: string) => { const incoming = keep(text); if (incoming.length === 0) return; const next = [...charsRef.current]; let cursor = index; for (const c of incoming) { if (cursor >= length) break; next[cursor] = c; cursor += 1; } commit(next); focusAt(cursor); }, [commit, focusAt, keep, length], ); const clear = useCallback(() => { commit(Array.from({ length }, () => "")); focusAt(0); }, [commit, focusAt, length]); const getCellProps = useCallback( (index: number): OtpCellProps => ({ ref: (el) => { refs.current[index] = el; }, value: chars[index] ?? "", disabled, type: "text", inputMode: mode === "numeric" ? "numeric" : "text", autoComplete: index === 0 ? "one-time-code" : "off", autoCorrect: "off", autoCapitalize: "off", spellCheck: false, onChange: (e) => { const previous = charsRef.current[index] ?? ""; const raw = e.currentTarget.value; const trimmed = raw.length > 1 && previous && raw.startsWith(previous) ? raw.slice(previous.length) : raw; const incoming = keep(trimmed); if (incoming.length === 0) { if (raw.length === 0 && previous) { const next = [...charsRef.current]; next[index] = ""; commit(next); } e.currentTarget.value = charsRef.current[index] ?? ""; return; } if (incoming.length === 1) { const next = [...charsRef.current]; next[index] = incoming; e.currentTarget.value = incoming; commit(next); if (index < length - 1) focusAt(index + 1); return; } fillFrom(index, incoming); }, onKeyDown: (e) => { if (e.key === "Backspace") { e.preventDefault(); const current = charsRef.current; const next = [...current]; if (current[index]) { next[index] = ""; commit(next); return; } if (index > 0) { next[index - 1] = ""; commit(next); focusAt(index - 1); } return; } if (e.key === "Delete") { e.preventDefault(); const next = [...charsRef.current]; next[index] = ""; commit(next); return; } if (e.key === "ArrowLeft") { e.preventDefault(); focusAt(index - 1); return; } if (e.key === "ArrowRight") { e.preventDefault(); focusAt(index + 1); return; } if (e.key === "Home") { e.preventDefault(); focusAt(0); return; } if (e.key === "End") { e.preventDefault(); focusAt(length - 1); } }, onPaste: (e) => { e.preventDefault(); const text = keep(e.clipboardData.getData("text")); fillFrom(text.length >= length ? 0 : index, text); }, onFocus: (e) => { e.currentTarget.select(); const firstEmpty = charsRef.current.findIndex((c) => c === ""); if (firstEmpty !== -1 && firstEmpty < index) { focusAt(firstEmpty); return; } setFocusedIndex(index); }, onBlur: (e) => { const to = e.relatedTarget as HTMLInputElement | null; if (to && refs.current.includes(to)) return; setFocusedIndex(-1); }, }), [chars, commit, disabled, fillFrom, focusAt, keep, length, mode], ); const value = chars.join(""); return { chars, value, length, complete: chars.length > 0 && chars.every((c) => c !== ""), focusedIndex, getCellProps, focusAt, clear, }; } export type OtpStatus = "idle" | "error" | "success"; export type OtpInputHandle = { clear: () => void; focus: () => void; }; export type OtpInputProps = { length?: number; mode?: OtpMode; defaultValue?: string; onChange?: (value: string) => void; onComplete?: (value: string) => void; status?: OtpStatus; errorMessage?: string; successMessage?: string; hint?: string; label?: string; groupEvery?: number; disabled?: boolean; autoFocus?: boolean; focusOnError?: boolean; className?: string; ref?: React.Ref; }; export function OtpInput({ length = 6, mode = "numeric", defaultValue = "", onChange, onComplete, status = "idle", errorMessage = "", successMessage = "", hint = "", label = "Verification code", groupEvery = 3, disabled = false, autoFocus = false, focusOnError = true, className = "", ref, }: OtpInputProps) { const reduced = useReducedMotion(); const statusId = useId(); const { chars, focusedIndex, getCellProps, focusAt, clear } = useOtpInput({ length, mode, defaultValue, disabled, onChange, onComplete, }); const wasError = useRef(false); const error = status === "error"; const success = status === "success"; useImperativeHandle( ref, () => ({ clear: () => { clear(); focusAt(0); }, focus: () => focusAt(0), }), [clear, focusAt], ); useEffect(() => { if (error && !wasError.current && focusOnError && !disabled) focusAt(0); wasError.current = error; }, [error, focusOnError, disabled, focusAt]); useEffect(() => { if (autoFocus && !disabled) focusAt(0); }, [autoFocus, disabled, focusAt]); const enter = reduced ? { duration: 0 } : { duration: 0.22, ease: EASE }; const swap = reduced ? { duration: 0 } : CROSSFADE; const hasStatus = hint.length > 0 || errorMessage.length > 0 || successMessage.length > 0; const message = error ? errorMessage : success ? successMessage : hint; const messageTone = error ? "text-red-600 dark:text-red-400" : success ? "text-emerald-600 dark:text-emerald-400" : "text-stone-500 dark:text-stone-400"; return (
{Array.from({ length }, (_, i) => { const char = chars[i] ?? ""; const active = focusedIndex === i; const gap = groupEvery > 0 && i > 0 && i % groupEvery === 0; return (
{char ? ( {char} ) : null} {active && !char && !disabled ? ( ) : null}
); })}
{hasStatus && ( <>
{message}
{message} )}
); } ```