{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "otp-input", "title": "OTP Input", "description": "A row of hand-drawn single-character boxes for one-time passwords.", "dependencies": [ "roughjs" ], "registryDependencies": [ "https://www.bydefaulthuman.fun/r/use-rough.json", "utils", "https://www.bydefaulthuman.fun/r/crumble-theme.json", "https://www.bydefaulthuman.fun/r/rough-lib.json" ], "files": [ { "path": "registry/new-york/ui/otp-input.tsx", "content": "\"use client\";\n\nimport {\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n type ClipboardEvent,\n type KeyboardEvent,\n} from \"react\";\nimport { useRough } from \"@/hooks/use-rough\";\nimport { cn } from \"@/lib/utils\";\nimport {\n CrumbleContext,\n resolveRoughVars,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\n\nexport interface OtpInputProps extends CrumbleColorProps {\n className?: string;\n disabled?: boolean;\n id?: string;\n label?: string;\n length?: number;\n onChange?: (value: string) => void;\n onComplete?: (value: string) => void;\n theme?: CrumbleTheme;\n}\n\nfunction OtpCell({\n cellId,\n disabled,\n focused,\n hasValue,\n theme,\n value,\n}: {\n cellId: string;\n disabled: boolean;\n focused: boolean;\n hasValue: boolean;\n theme: CrumbleTheme;\n value: string;\n}) {\n const externalSvgRef = useRef(null);\n const { drawRect, svgRef } = useRough({\n stableId: cellId,\n svgRef: externalSvgRef,\n theme,\n variant: focused ? \"interactive\" : \"border\",\n });\n\n const draw = useCallback(() => {\n const svg = svgRef.current;\n if (!svg) return;\n\n svg.replaceChildren();\n const SIZE = 44;\n svg.setAttribute(\"width\", String(SIZE));\n svg.setAttribute(\"height\", String(SIZE));\n svg.setAttribute(\"viewBox\", `0 0 ${SIZE} ${SIZE}`);\n\n const stroke = disabled\n ? \"var(--cr-stroke-muted)\"\n : focused || hasValue\n ? \"var(--cr-stroke)\"\n : \"var(--cr-stroke-muted)\";\n\n const rect = drawRect(1, 1, SIZE - 2, SIZE - 2, {\n fill: \"none\",\n stroke,\n });\n if (rect) svg.appendChild(rect);\n }, [disabled, drawRect, focused, hasValue, svgRef]);\n\n useEffect(() => {\n const id = requestAnimationFrame(() => draw());\n return () => cancelAnimationFrame(id);\n }, [draw]);\n\n return (\n
\n \n \n {value ? \"•\" : null}\n \n
\n );\n}\n\nexport function OtpInput({\n className,\n disabled = false,\n fill,\n id,\n label,\n length = 6,\n onChange,\n onComplete,\n stroke,\n strokeMuted,\n theme: themeProp,\n}: OtpInputProps) {\n const [digits, setDigits] = useState(Array(length).fill(\"\"));\n const [focusIndex, setFocusIndex] = useState(-1);\n const inputRefs = useRef<(HTMLInputElement | null)[]>([]);\n const otpId = id ?? \"otp\";\n const { theme: contextTheme } = useContext(CrumbleContext);\n const theme = themeProp ?? contextTheme;\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n\n const updateDigits = (next: string[]) => {\n setDigits(next);\n const val = next.join(\"\");\n onChange?.(val);\n if (val.length === length && next.every((d) => d !== \"\")) {\n onComplete?.(val);\n }\n };\n\n const handleKeyDown = (e: KeyboardEvent, index: number) => {\n if (e.key === \"Backspace\") {\n const next = [...digits];\n if (next[index]) {\n next[index] = \"\";\n updateDigits(next);\n } else if (index > 0) {\n next[index - 1] = \"\";\n updateDigits(next);\n inputRefs.current[index - 1]?.focus();\n }\n } else if (e.key === \"ArrowLeft\" && index > 0) {\n inputRefs.current[index - 1]?.focus();\n } else if (e.key === \"ArrowRight\" && index < length - 1) {\n inputRefs.current[index + 1]?.focus();\n }\n };\n\n const handleInput = (raw: string, index: number) => {\n const char = raw.replace(/\\D/g, \"\").slice(-1);\n if (!char) return;\n const next = [...digits];\n next[index] = char;\n updateDigits(next);\n if (index < length - 1) inputRefs.current[index + 1]?.focus();\n };\n\n const handlePaste = (e: ClipboardEvent, index: number) => {\n e.preventDefault();\n const pasted = e.clipboardData\n .getData(\"text\")\n .replace(/\\D/g, \"\")\n .slice(0, length - index);\n if (!pasted) return;\n const next = [...digits];\n for (let i = 0; i < pasted.length; i++) next[index + i] = pasted[i];\n updateDigits(next);\n const lastFilled = Math.min(index + pasted.length, length - 1);\n inputRefs.current[lastFilled]?.focus();\n };\n\n return (\n
\n {label ? (\n \n ) : null}\n
\n {Array.from({ length }, (_, i) => (\n
\n \n {\n inputRefs.current[i] = el;\n }}\n id={i === 0 ? otpId : undefined}\n type=\"text\"\n inputMode=\"numeric\"\n maxLength={1}\n disabled={disabled}\n value={digits[i]}\n onFocus={() => setFocusIndex(i)}\n onBlur={() => setFocusIndex(-1)}\n onChange={(e) => handleInput(e.target.value, i)}\n onKeyDown={(e) => handleKeyDown(e, i)}\n onPaste={(e) => handlePaste(e, i)}\n className=\"absolute inset-0 w-full h-full opacity-0 cursor-text\"\n aria-label={`digit ${i + 1}`}\n />\n
\n ))}\n
\n
\n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/otp-input.tsx" } ], "type": "registry:ui" }