{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "title": "OTP Input",
  "author": "AlgorizeTech",
  "description": "Ask the user to pick one of 2 otp input variants: https://avaraui.com/view/otp-input. This item holds 2 of them as looks of one component (default, underlined). Skip the question if the user already named one. A segmented one-time-passcode field with auto-advance, paste handling, and backspace navigation. Use it for email or SMS verification steps.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/OtpInputBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport React, { forwardRef, useEffect, useRef, useState } from \"react\";\nimport { getThemeStyles } from \"./otp-input-theme\";\n\n// ==========================================\n// CONFIGURATION\n// ==========================================\nexport type OtpVariant = \"default\" | \"filled\" | \"underlined\";\nexport type OtpColor = \"indigo\" | \"rose\" | \"emerald\" | \"zinc\" | (string & {});\n\n// ==========================================\n// COMPONENT\n// ==========================================\nexport interface OtpInputProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  length?: number;\n  value?: string;\n  onChange?: (value: string) => void;\n  onComplete?: (value: string) => void;\n  mask?: boolean;\n  variant?: OtpVariant;\n  color?: OtpColor;\n  disabled?: boolean;\n}\n\nexport const OtpInputBlock = forwardRef<HTMLDivElement, OtpInputProps>(\n  (\n    {\n      length = 6,\n      value = \"\",\n      onChange,\n      onComplete,\n      mask = false,\n      variant = \"default\",\n      color = \"primary\",\n      disabled = false,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const { theme, style } = getThemeStyles(color as string);\n    const [otp, setOtp] = useState<string[]>(Array(length).fill(\"\"));\n    const inputRefs = useRef<(HTMLInputElement | null)[]>([]);\n\n    // Sync external value to internal array state\n    useEffect(() => {\n      if (value !== undefined) {\n        const valArray = value.split(\"\").slice(0, length);\n        const paddedArray = [\n          ...valArray,\n          ...Array(length - valArray.length).fill(\"\"),\n        ];\n        setOtp(paddedArray);\n      }\n    }, [value, length]);\n\n    const triggerChange = (newOtp: string[]) => {\n      const otpString = newOtp.join(\"\");\n      if (onChange) onChange(otpString);\n      if (onComplete && otpString.length === length) {\n        onComplete(otpString);\n      }\n    };\n\n    const handleChange = (\n      e: React.ChangeEvent<HTMLInputElement>,\n      index: number,\n    ) => {\n      const val = e.target.value;\n      if (isNaN(Number(val)) && val !== \"\") return; // Only allow numbers (Optional: remove this to allow alphanumeric)\n\n      const newOtp = [...otp];\n      // Only take the last character typed to handle fast typing\n      newOtp[index] = val.substring(val.length - 1);\n      setOtp(newOtp);\n      triggerChange(newOtp);\n\n      // Move to next input if value is typed\n      if (val !== \"\" && index < length - 1) {\n        inputRefs.current[index + 1]?.focus();\n      }\n    };\n\n    const handleKeyDown = (\n      e: React.KeyboardEvent<HTMLInputElement>,\n      index: number,\n    ) => {\n      if (e.key === \"Backspace\") {\n        if (!otp[index] && index > 0) {\n          // If current is empty, move to previous and clear it\n          const newOtp = [...otp];\n          newOtp[index - 1] = \"\";\n          setOtp(newOtp);\n          triggerChange(newOtp);\n          inputRefs.current[index - 1]?.focus();\n        } else {\n          // Normal clear\n          const newOtp = [...otp];\n          newOtp[index] = \"\";\n          setOtp(newOtp);\n          triggerChange(newOtp);\n        }\n      }\n\n      // Arrow navigation\n      if (e.key === \"ArrowLeft\" && index > 0) {\n        e.preventDefault();\n        inputRefs.current[index - 1]?.focus();\n        inputRefs.current[index - 1]?.select();\n      }\n      if (e.key === \"ArrowRight\" && index < length - 1) {\n        e.preventDefault();\n        inputRefs.current[index + 1]?.focus();\n        inputRefs.current[index + 1]?.select();\n      }\n    };\n\n    const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {\n      e.preventDefault();\n      const pastedData = e.clipboardData\n        .getData(\"text/plain\")\n        .slice(0, length)\n        .split(\"\");\n\n      // Filter out non-numeric characters if desired\n      const numericData = pastedData.filter(\n        (char) => !isNaN(Number(char)) && char !== \" \",\n      );\n\n      if (numericData.length === 0) return;\n\n      const newOtp = [...otp];\n      numericData.forEach((char, i) => {\n        if (i < length) newOtp[i] = char;\n      });\n\n      setOtp(newOtp);\n      triggerChange(newOtp);\n\n      // Focus the next empty input, or the last input if full\n      const nextEmptyIndex = newOtp.findIndex((val) => val === \"\");\n      const focusIndex = nextEmptyIndex === -1 ? length - 1 : nextEmptyIndex;\n      inputRefs.current[focusIndex]?.focus();\n    };\n\n    const getVariantStyles = () => {\n      // Fluid sizing: each box is an equal share of the row (never wider than\n      // 3rem) so any length fits its container instead of overflowing it.\n      // `min-w-0` is required — an input's intrinsic width (size=20) would\n      // otherwise block flex shrinking. Font and corner radius scale with the\n      // container width divided by the digit count, so 8 digits stay legible on\n      // a phone and small boxes don't read as pills. Both cap at the values the\n      // 48px box used, leaving 4/6 digits looking exactly as before.\n      const base =\n        \"min-w-0 flex-1 basis-0 max-w-12 aspect-[5/6] px-0 text-center text-[clamp(0.8125rem,calc(38cqw/var(--otp-len,6)),1.25rem)] rounded-[clamp(0.375rem,calc(20cqw/var(--otp-len,6)),0.75rem)] font-semibold transition-all duration-200 outline-none placeholder:text-muted-foreground/40\";\n\n      switch (variant) {\n        case \"filled\":\n          return cn(\n            base,\n            \"border border-transparent bg-muted hover:bg-muted\",\n            \"focus:bg-background focus:ring-2\",\n            theme.focusRing,\n          );\n        case \"underlined\":\n          return cn(\n            base,\n            \"rounded-none border-0 border-b-2 border-border bg-transparent px-0\",\n            \"focus:ring-0\",\n            theme.border,\n          );\n        case \"default\":\n        default:\n          return cn(\n            base,\n            \"border border-border bg-background shadow-sm\",\n            \"focus:ring-2\",\n            theme.focusRing,\n          );\n      }\n    };\n\n    const inputClasses = getVariantStyles();\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          \"@container/otp flex w-full max-w-full items-center justify-center\",\n          length > 6 ? \"gap-1.5 sm:gap-2 md:gap-3\" : \"gap-2 sm:gap-3\",\n          className,\n        )}\n        style={{ ...style, \"--otp-len\": length } as React.CSSProperties}\n        {...props}\n      >\n        {otp.map((digit, index) => (\n          <input\n            key={index}\n            ref={(el) => {\n              inputRefs.current[index] = el;\n            }}\n            type={mask ? \"password\" : \"text\"}\n            inputMode=\"numeric\"\n            autoComplete=\"one-time-code\"\n            pattern=\"\\d*\"\n            maxLength={1}\n            value={digit}\n            onChange={(e) => handleChange(e, index)}\n            onKeyDown={(e) => handleKeyDown(e, index)}\n            onPaste={handlePaste}\n            disabled={disabled}\n            className={cn(\n              inputClasses,\n              digit ? theme.text : \"text-foreground\",\n              disabled && \"opacity-50 cursor-not-allowed\",\n            )}\n            aria-label={`Digit ${index + 1} of ${length}`}\n          />\n        ))}\n      </div>\n    );\n  },\n);\nOtpInputBlock.displayName = \"OtpInputBlock\";\n",
      "type": "registry:component",
      "target": "components/custom/blocks/OtpInputBlock.tsx"
    },
    {
      "path": "src/components/custom/blocks/otp-input-theme.ts",
      "content": "\"use client\";\n\nimport React from \"react\";\n\nconst colorThemes: Record<string, Record<string, string>> = {\n  primary: {\n    focusRing: \"focus:ring-primary focus:border-primary/30\",\n    text: \"text-primary\",\n    border: \"border-primary/30\",\n  },\n  indigo: {\n    focusRing: \"focus:ring-indigo-500 focus:border-indigo-500\",\n    text: \"text-indigo-600 dark:text-indigo-400\",\n    border: \"border-indigo-500\",\n  },\n  rose: {\n    focusRing: \"focus:ring-rose-500 focus:border-rose-500\",\n    text: \"text-rose-600 dark:text-rose-400\",\n    border: \"border-rose-500\",\n  },\n  emerald: {\n    focusRing: \"focus:ring-emerald-500 focus:border-emerald-500\",\n    text: \"text-emerald-600 dark:text-emerald-400\",\n    border: \"border-emerald-500\",\n  },\n  zinc: {\n    focusRing:\n      \"focus:ring-zinc-900 dark:focus:ring-zinc-100 focus:border-zinc-900 dark:focus:border-zinc-100\",\n    text: \"text-zinc-900 dark:text-zinc-100\",\n    border: \"border-zinc-900 dark:border-zinc-100\",\n  },\n};\n\nconst hexToRgb = (hex: string) => {\n  let cleanHex = hex.replace(/^#/, \"\");\n  if (cleanHex.length === 3)\n    cleanHex = cleanHex\n      .split(\"\")\n      .map((x) => x + x)\n      .join(\"\");\n  const result = /^([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(cleanHex);\n  return result\n    ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`\n    : \"99, 102, 241\";\n};\n\nexport const getThemeStyles = (color: string) => {\n  if (color && color.startsWith(\"#\")) {\n    const rgb = hexToRgb(color);\n    return {\n      theme: {\n        focusRing:\n          \"focus:ring-[var(--otp-color)] focus:border-[var(--otp-color)]\",\n        text: \"text-[var(--otp-color)]\",\n        border: \"border-[var(--otp-color)]\",\n      },\n      style: {\n        \"--otp-color\": color,\n        \"--otp-color-rgb\": rgb,\n      } as React.CSSProperties,\n    };\n  }\n  return { theme: colorThemes[color] || colorThemes.primary, style: {} };\n};\n",
      "type": "registry:component",
      "target": "components/custom/blocks/otp-input-theme.ts"
    }
  ],
  "docs": "Pasting a full code into any cell distributes the digits across all cells.",
  "categories": [
    "forms",
    "authentication"
  ],
  "type": "registry:component"
}
