{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "otp-input", "title": "OTP Input", "description": "A one-time-code input whose characters roll into place behind a caret that slides from slot to slot.", "dependencies": [ "motion" ], "registryDependencies": [ "amitgajare2/ariseui/utils" ], "files": [ { "path": "components/ui/otp-input.tsx", "content": "\"use client\";\r\n\r\nimport { useRef, useState, type ComponentProps } from \"react\";\r\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\r\nimport { cn } from \"@/lib/utils\";\r\n\r\nconst PATTERNS = {\r\n numbers: /^[0-9]$/,\r\n letters: /^[a-zA-Z]$/,\r\n both: /^[a-zA-Z0-9]$/,\r\n} as const;\r\n\r\n// success draws its own ring in svg, so no css ring here\r\nconst RING = {\r\n idle: \"focus-visible:ring-2 focus-visible:ring-[#868593]/50\",\r\n success: \"\",\r\n error: \"ring-2 ring-[#FF3B30]/70 delay-150\",\r\n} as const;\r\n\r\nconst SUCCESS = \"#34C759\";\r\n\r\nconst SIZES = {\r\n sm: {\r\n box: \"size-10 rounded-lg\",\r\n text: \"text-base\",\r\n caret: \"h-5\",\r\n gap: \"gap-1.5\",\r\n px: 40,\r\n radius: 8,\r\n },\r\n md: {\r\n box: \"size-12 rounded-xl\",\r\n text: \"text-lg\",\r\n caret: \"h-6\",\r\n gap: \"gap-2\",\r\n px: 48,\r\n radius: 12,\r\n },\r\n lg: {\r\n box: \"size-14 rounded-2xl\",\r\n text: \"text-xl\",\r\n caret: \"h-7\",\r\n gap: \"gap-2.5\",\r\n px: 56,\r\n radius: 16,\r\n },\r\n} as const;\r\n\r\nconst SLOT_CLASS =\r\n \"bg-[#F4F4F9] dark:bg-[#262626] text-center font-medium text-transparent caret-transparent outline-none transition-shadow duration-200 selection:bg-transparent disabled:cursor-not-allowed disabled:opacity-50\";\r\n\r\nconst ROLL_SPRING = { type: \"spring\", stiffness: 500, damping: 34 } as const;\r\nconst CARET_SPRING = { type: \"spring\", stiffness: 500, damping: 40 } as const;\r\nconst BLINK = {\r\n duration: 1.1,\r\n times: [0, 0.5, 0.5, 1],\r\n repeat: Infinity,\r\n ease: \"linear\" as const,\r\n};\r\n\r\nconst ROLL = {\r\n initial: { y: \"110%\" },\r\n exit: (cleared: boolean) => ({ y: cleared ? \"110%\" : \"-110%\" }),\r\n};\r\n\r\nconst SHAKE = [0, -5, 4, -2, 0];\r\n\r\nconst toSlots = (code: string, length: number) =>\r\n Array.from({ length }, (_, i) => code[i] ?? \"\");\r\n\r\nexport type OtpStatus = \"idle\" | \"success\" | \"error\";\r\n\r\nexport type OtpInputProps = Omit<\r\n ComponentProps<\"div\">,\r\n \"onChange\" | \"value\" | \"defaultValue\"\r\n> & {\r\n length?: number;\r\n value?: string;\r\n defaultValue?: string;\r\n onChange?: (value: string) => void;\r\n onComplete?: (value: string) => void;\r\n type?: keyof typeof PATTERNS;\r\n size?: keyof typeof SIZES;\r\n status?: OtpStatus;\r\n mask?: boolean;\r\n disabled?: boolean;\r\n autoFocus?: boolean;\r\n slotClassName?: string;\r\n};\r\n\r\nexport function OtpInput({\r\n length = 6,\r\n value,\r\n defaultValue = \"\",\r\n onChange,\r\n onComplete,\r\n type = \"numbers\",\r\n size = \"md\",\r\n status = \"idle\",\r\n mask = false,\r\n disabled,\r\n autoFocus,\r\n className,\r\n slotClassName,\r\n ...props\r\n}: OtpInputProps) {\r\n const [uncontrolled, setUncontrolled] = useState(() =>\r\n toSlots(defaultValue, length),\r\n );\r\n const [cleared, setCleared] = useState(false);\r\n const [focused, setFocused] = useState(null);\r\n const [caretX, setCaretX] = useState(0);\r\n const inputs = useRef<(HTMLInputElement | null)[]>([]);\r\n const cells = useRef<(HTMLDivElement | null)[]>([]);\r\n // the slot the user deliberately moved to, so a full code only changes on purpose\r\n const editingAt = useRef(null);\r\n const reduceMotion = useReducedMotion();\r\n\r\n // padded, not joined: joining would close a gap left by a mid-code backspace\r\n const slots =\r\n value === undefined\r\n ? Array.from({ length }, (_, i) => uncontrolled[i] ?? \"\")\r\n : toSlots(value, length);\r\n const numeric = type === \"numbers\";\r\n const scale = SIZES[size];\r\n const caretVisible = focused !== null && !slots[focused];\r\n\r\n const commit = (next: string[]) => {\r\n if (value === undefined) setUncontrolled(next);\r\n const code = next.join(\"\");\r\n onChange?.(code);\r\n if (next.every(Boolean)) onComplete?.(code);\r\n };\r\n\r\n const setCharAt = (index: number, char: string) => {\r\n setCleared(!char);\r\n commit(slots.map((slot, i) => (i === index ? char : slot)));\r\n };\r\n\r\n const focusAt = (index: number) => {\r\n const input = inputs.current[Math.min(Math.max(index, 0), length - 1)];\r\n input?.focus();\r\n input?.select();\r\n };\r\n\r\n const fill = (index: number, chars: string[]) => {\r\n const room = Math.min(chars.length, length - index);\r\n const next = [...slots];\r\n chars.slice(0, room).forEach((char, i) => {\r\n next[index + i] = char;\r\n });\r\n setCleared(false);\r\n commit(next);\r\n editingAt.current = null;\r\n focusAt(index + room);\r\n };\r\n\r\n const handleChange = (index: number, raw: string) => {\r\n const chars = raw.split(\"\").filter((char) => PATTERNS[type].test(char));\r\n if (!chars.length) return;\r\n\r\n // typing into a filled slot appends, so keep only the new character\r\n const typed =\r\n chars.length === 1\r\n ? chars[0]\r\n : chars.length === 2 && chars[0] === slots[index]\r\n ? chars[1]\r\n : null;\r\n\r\n if (typed === null) {\r\n // anything longer arrived at once: a paste or an SMS autofill\r\n fill(index, chars);\r\n return;\r\n }\r\n\r\n if (slots.every(Boolean) && editingAt.current !== index) return;\r\n\r\n setCharAt(index, typed);\r\n editingAt.current = null;\r\n focusAt(index + 1);\r\n };\r\n\r\n const handleKeyDown = (\r\n index: number,\r\n event: React.KeyboardEvent,\r\n ) => {\r\n const actions: Record void> = {\r\n ArrowLeft: () => {\r\n editingAt.current = Math.max(index - 1, 0);\r\n focusAt(index - 1);\r\n },\r\n ArrowRight: () => {\r\n editingAt.current = Math.min(index + 1, length - 1);\r\n focusAt(index + 1);\r\n },\r\n Backspace: () => {\r\n if (slots[index]) {\r\n setCharAt(index, \"\");\r\n } else if (index > 0) {\r\n setCharAt(index - 1, \"\");\r\n focusAt(index - 1);\r\n }\r\n },\r\n };\r\n\r\n const action = actions[event.key];\r\n if (!action) return;\r\n event.preventDefault();\r\n action();\r\n };\r\n\r\n const handlePaste = (\r\n index: number,\r\n event: React.ClipboardEvent,\r\n ) => {\r\n event.preventDefault();\r\n const pasted = event.clipboardData\r\n .getData(\"text\")\r\n .split(\"\")\r\n .filter((char) => PATTERNS[type].test(char));\r\n if (pasted.length) fill(index, pasted);\r\n };\r\n\r\n // clicking past the first gap lands on the gap, so a code stays contiguous\r\n const handlePointerDown = (\r\n index: number,\r\n event: React.PointerEvent,\r\n ) => {\r\n const firstEmpty = slots.findIndex((slot) => !slot);\r\n const target = firstEmpty === -1 ? index : Math.min(index, firstEmpty);\r\n editingAt.current = target;\r\n if (target === index) return;\r\n event.preventDefault();\r\n focusAt(target);\r\n };\r\n\r\n return (\r\n \r\n {\r\n const index = inputs.current.indexOf(\r\n event.target as HTMLInputElement,\r\n );\r\n const cell = cells.current[index];\r\n setFocused(index);\r\n if (cell) setCaretX(cell.offsetLeft + cell.offsetWidth / 2);\r\n }}\r\n onBlur={(event) => {\r\n if (!event.currentTarget.contains(event.relatedTarget as Node)) {\r\n setFocused(null);\r\n }\r\n }}\r\n animate={{\r\n x: status === \"error\" && !reduceMotion ? SHAKE : 0,\r\n }}\r\n transition={{ duration: 0.32, ease: \"easeOut\" }}\r\n data-slot=\"otp-input-row\"\r\n className={cn(\"relative flex items-center\", scale.gap)}\r\n >\r\n {slots.map((slot, index) => (\r\n {\r\n cells.current[index] = el;\r\n }}\r\n data-slot=\"otp-input-cell\"\r\n data-filled={Boolean(slot)}\r\n className=\"relative\"\r\n >\r\n {\r\n inputs.current[index] = el;\r\n }}\r\n data-slot=\"otp-input-slot\"\r\n data-filled={Boolean(slot)}\r\n value={slot}\r\n onChange={(event) => handleChange(index, event.target.value)}\r\n onKeyDown={(event) => handleKeyDown(index, event)}\r\n onPaste={(event) => handlePaste(index, event)}\r\n onPointerDown={(event) => handlePointerDown(index, event)}\r\n onFocus={(event) => event.target.select()}\r\n type={mask ? \"password\" : \"text\"}\r\n inputMode={numeric ? \"numeric\" : \"text\"}\r\n autoCapitalize={numeric ? undefined : \"characters\"}\r\n autoComplete={index === 0 ? \"one-time-code\" : \"off\"}\r\n autoFocus={autoFocus && index === 0}\r\n disabled={disabled}\r\n aria-label={`${numeric ? \"Digit\" : \"Character\"} ${index + 1} of ${length}`}\r\n className={cn(\r\n SLOT_CLASS,\r\n scale.box,\r\n scale.text,\r\n RING[status],\r\n slotClassName,\r\n )}\r\n />\r\n\r\n \r\n {status === \"success\" && (\r\n \r\n \r\n \r\n )}\r\n \r\n\r\n \r\n \r\n {slot && (\r\n \r\n {mask ? \"•\" : slot}\r\n \r\n )}\r\n \r\n \r\n \r\n ))}\r\n\r\n {caretVisible && (\r\n \r\n )}\r\n \r\n \r\n );\r\n}\r\n\r\nexport default OtpInput;\r\n", "type": "registry:ui" } ], "type": "registry:ui" }