{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "title": "OTP Input",
  "description": "Verification code input with springy digits, a gliding focus ring and animated success and error states.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/otp-input/otp-input.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion, type Transition, type Variants } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type OtpInputVariant = \"boxes\" | \"line\"\nexport type OtpInputStatus = \"idle\" | \"loading\" | \"success\" | \"error\"\nexport type OtpInputPattern = \"numeric\" | \"alphanumeric\"\n\nexport interface OtpInputProps {\n  /** Number of characters in the code. Default: 6 */\n  length?: number\n  /** Visual style: rounded boxes or minimal underlines. Default: \"boxes\" */\n  variant?: OtpInputVariant\n  /** Controlled value. */\n  value?: string\n  /** Initial value when uncontrolled. Default: \"\" */\n  defaultValue?: string\n  /** Called on every change with the sanitized value. */\n  onChange?: (value: string) => void\n  /** Called once every slot is filled. */\n  onComplete?: (code: string) => void\n  /** Checks the completed code. Shows a loading shimmer while pending, then success or error. */\n  verify?: (code: string) => Promise<boolean> | boolean\n  /** Controlled status. Overrides the internal verify state. */\n  status?: OtpInputStatus\n  /** Show dots instead of characters. Default: false */\n  masked?: boolean\n  /** Accepted characters. Default: \"numeric\" */\n  pattern?: OtpInputPattern\n  /** Focus the input on mount. Default: false */\n  autoFocus?: boolean\n  /** Disable input. Default: false */\n  disabled?: boolean\n  /** Color of the success state (any CSS color). Default: \"#22c55e\" */\n  successColor?: string\n  /** Color of the error state (any CSS color). Default: \"#ef4444\" */\n  errorColor?: string\n  /** Clear the code and refocus after an error. Default: true */\n  resetOnError?: boolean\n  /** Boxes variant: collapse the slots into a check badge on success. Default: true */\n  collapseOnSuccess?: boolean\n  /** Accessible label for the input. Default: \"Verification code\" */\n  label?: string\n  className?: string\n}\n\nconst BOX = 48\nconst BOX_GAP = 8\nconst LINE = 40\nconst LINE_GAP = 12\n\nconst pop: Transition = { type: \"spring\", stiffness: 520, damping: 30, mass: 0.6 }\nconst glide: Transition = { type: \"spring\", stiffness: 480, damping: 38 }\n\nconst rowShake: Variants = {\n  rest: { x: 0 },\n  shake: { x: [0, -10, 9, -6, 4, -2, 0], transition: { duration: 0.5, ease: \"easeOut\" } },\n}\n\nconst slotWave: Variants = {\n  rest: { x: 0 },\n  shake: (i: number) => ({\n    x: [0, -6, 5, -3, 1.5, 0],\n    transition: { duration: 0.5, delay: i * 0.035, ease: \"easeOut\" },\n  }),\n}\n\nconst sanitize = (raw: string, pattern: OtpInputPattern) =>\n  pattern === \"numeric\" ? raw.replace(/\\D/g, \"\") : raw.replace(/[^a-z0-9]/gi, \"\").toUpperCase()\n\nfunction Check({ className, delay = 0, instant }: { className?: string; delay?: number; instant?: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" className={className} fill=\"none\" stroke=\"currentColor\" strokeWidth={2.2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <motion.path\n        d=\"M3 8.5 6.5 12 13 4.5\"\n        initial={{ pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={instant ? { duration: 0 } : { duration: 0.35, ease: \"easeOut\", delay }}\n      />\n    </svg>\n  )\n}\n\nfunction Caret({ className, reduced }: { className?: string; reduced: boolean }) {\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      className={cn(\"pointer-events-none absolute w-px rounded-full bg-foreground\", className)}\n      animate={{ opacity: reduced ? 1 : [1, 1, 0, 0] }}\n      transition={reduced ? { duration: 0 } : { duration: 1.05, times: [0, 0.5, 0.6, 1], repeat: Infinity, ease: \"linear\" }}\n    />\n  )\n}\n\nexport function OtpInput({\n  length = 6,\n  variant = \"boxes\",\n  value: valueProp,\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  verify,\n  status: statusProp,\n  masked = false,\n  pattern = \"numeric\",\n  autoFocus = false,\n  disabled = false,\n  successColor = \"#22c55e\",\n  errorColor = \"#ef4444\",\n  resetOnError = true,\n  collapseOnSuccess = true,\n  label = \"Verification code\",\n  className,\n}: OtpInputProps) {\n  const id = useId()\n  const reduced = useReducedMotion() ?? false\n  const inputRef = useRef<HTMLInputElement>(null)\n  const runRef = useRef(0)\n\n  const [inner, setInner] = useState(defaultValue)\n  const [innerStatus, setInnerStatus] = useState<OtpInputStatus>(\"idle\")\n  const [focused, setFocused] = useState(false)\n  const [sel, setSel] = useState<number | null>(null)\n  const [collapsed, setCollapsed] = useState(false)\n\n  const status = statusProp ?? innerStatus\n  const [prevStatus, setPrevStatus] = useState(status)\n  const [tone, setTone] = useState<\"success\" | \"error\">(status === \"success\" ? \"success\" : \"error\")\n  if (status !== prevStatus) {\n    setPrevStatus(status)\n    setCollapsed(false)\n    if (status === \"success\" || status === \"error\") setTone(status)\n  }\n\n  const code = sanitize(valueProp ?? inner, pattern).slice(0, length)\n  const active = Math.min(sel ?? code.length, code.length, length - 1)\n  const locked = disabled || status === \"loading\" || status === \"success\" || (status === \"error\" && resetOnError)\n  const showCursor = focused && !locked\n  const state = status === \"error\" ? \"error\" : status === \"success\" ? \"success\" : \"idle\"\n  const toneColor = tone === \"success\" ? successColor : errorColor\n  const t = (tr: Transition): Transition => (reduced ? { duration: 0 } : tr)\n\n  const write = (next: string) => {\n    if (valueProp === undefined) setInner(next)\n    setSel(next.length)\n    onChange?.(next)\n  }\n\n  const complete = (next: string) => {\n    onComplete?.(next)\n    if (!verify) return\n    const run = ++runRef.current\n    setInnerStatus(\"loading\")\n    Promise.resolve()\n      .then(() => verify(next))\n      .then(\n        (ok) => ok,\n        () => false\n      )\n      .then((ok) => {\n        if (runRef.current === run) setInnerStatus(ok ? \"success\" : \"error\")\n      })\n  }\n\n  const handleInput = (raw: string, caret?: number | null) => {\n    if (locked) return\n    const next = sanitize(raw, pattern).slice(0, length)\n    if (next === code) return\n    runRef.current++\n    if (innerStatus !== \"idle\") setInnerStatus(\"idle\")\n    write(next)\n    if (caret != null) setSel(Math.min(caret, next.length))\n    if (next.length === length) complete(next)\n  }\n\n  // Error: let the shake play, then clear right-to-left and refocus\n  useEffect(() => {\n    if (status !== \"error\" || !resetOnError) return\n    const snapshot = code\n    const start = reduced ? 500 : 750\n    const step = reduced ? 0 : 45\n    const timers = Array.from({ length: snapshot.length }, (_, k) =>\n      setTimeout(() => write(snapshot.slice(0, snapshot.length - 1 - k)), start + k * step)\n    )\n    timers.push(\n      setTimeout(() => {\n        setInnerStatus(\"idle\")\n        inputRef.current?.focus()\n      }, start + snapshot.length * step + 120)\n    )\n    return () => timers.forEach(clearTimeout)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [status, resetOnError])\n\n  useEffect(() => {\n    if (status !== \"success\" || variant !== \"boxes\" || !collapseOnSuccess) return\n    const timer = setTimeout(() => setCollapsed(true), reduced ? 300 : length * 60 + 500)\n    return () => clearTimeout(timer)\n  }, [status, variant, collapseOnSuccess, length, reduced])\n\n  const slots = Array.from({ length }, (_, i) => i)\n  const center = (length - 1) / 2\n\n  const boxes = (\n    <motion.div\n      className=\"relative flex\"\n      style={{ gap: BOX_GAP }}\n      variants={rowShake}\n      initial={false}\n      animate={state === \"error\" && !reduced ? \"shake\" : \"rest\"}\n    >\n      {slots.map((i) => {\n        const char = code[i]\n        const isActive = showCursor && i === active\n        return (\n          <motion.div\n            key={i}\n            className=\"relative grid place-items-center rounded-xl border bg-card text-xl font-medium tabular-nums text-foreground shadow-xs\"\n            style={{ width: BOX, height: BOX }}\n            initial={false}\n            animate={collapsed ? { x: (center - i) * (BOX + BOX_GAP), scale: 0.5, opacity: 0 } : { x: 0, scale: 1, opacity: 1 }}\n            transition={t({ type: \"spring\", stiffness: 380, damping: 32, delay: collapsed ? Math.abs(center - i) * 0.02 : 0 })}\n          >\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute -inset-px rounded-[inherit] border\"\n              style={{ borderColor: toneColor, backgroundColor: `color-mix(in oklab, ${toneColor} 12%, transparent)` }}\n              initial={false}\n              animate={{ opacity: state === \"idle\" ? 0 : 1 }}\n              transition={t({ duration: 0.25, delay: state === \"success\" ? i * 0.06 : 0 })}\n            />\n            {status === \"loading\" && (\n              <motion.span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-0 rounded-[inherit] bg-foreground/[0.07]\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: reduced ? 1 : [0, 1, 0] }}\n                transition={reduced ? { duration: 0 } : { duration: 1.1, repeat: Infinity, delay: i * 0.08, ease: \"easeInOut\" }}\n              />\n            )}\n            {isActive && (\n              <motion.span\n                layoutId={`${id}-ring`}\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute -inset-px rounded-xl border border-foreground/45 ring-4 ring-foreground/[0.07]\"\n                transition={t(glide)}\n              />\n            )}\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {char && (\n                <motion.span\n                  key={char}\n                  className=\"relative\"\n                  initial={{ opacity: 0, scale: 0.6, y: 6, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n                  transition={t(pop)}\n                >\n                  {masked ? <span className=\"block size-2.5 rounded-full bg-current\" /> : char}\n                </motion.span>\n              )}\n            </AnimatePresence>\n            {isActive && !char && <Caret className=\"h-5\" reduced={reduced} />}\n          </motion.div>\n        )\n      })}\n      <AnimatePresence>\n        {collapsed && (\n          <motion.div\n            className=\"absolute inset-0 m-auto grid place-items-center rounded-full text-white\"\n            style={{\n              width: BOX,\n              height: BOX,\n              backgroundColor: successColor,\n              boxShadow: `0 0 0 6px color-mix(in oklab, ${successColor} 16%, transparent)`,\n            }}\n            initial={{ scale: 0.4, opacity: 0 }}\n            animate={{ scale: 1, opacity: 1 }}\n            exit={{ scale: 0.4, opacity: 0 }}\n            transition={t({ type: \"spring\", stiffness: 420, damping: 24, delay: 0.12 })}\n          >\n            <Check className=\"size-5\" delay={0.25} instant={reduced} />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.div>\n  )\n\n  const merged = state === \"success\"\n  const line = (\n    <div className=\"relative flex\" style={{ gap: LINE_GAP }}>\n      {slots.map((i) => {\n        const char = code[i]\n        const isActive = showCursor && i === active\n        return (\n          <motion.div\n            key={i}\n            custom={i}\n            variants={slotWave}\n            initial={false}\n            animate={state === \"error\" && !reduced ? \"shake\" : \"rest\"}\n            className=\"relative flex h-16 items-center justify-center text-4xl font-light tabular-nums tracking-tight text-foreground\"\n            style={{ width: LINE }}\n          >\n            <motion.span\n              className=\"relative\"\n              initial={false}\n              animate={{ y: merged ? -4 : 0 }}\n              transition={t({ ...pop, delay: merged ? 0.15 + i * 0.04 : 0 })}\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                {char && (\n                  <motion.span\n                    key={char}\n                    className=\"block\"\n                    initial={{ opacity: 0, y: 18, filter: \"blur(3px)\" }}\n                    animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                    exit={{ opacity: 0, y: 10, filter: \"blur(3px)\" }}\n                    transition={t(pop)}\n                  >\n                    {masked ? <span className=\"my-3 block size-3 rounded-full bg-current\" /> : char}\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </motion.span>\n            {isActive && !char && <Caret className=\"h-8\" reduced={reduced} />}\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"absolute inset-x-0 bottom-0 h-px origin-center\"\n              initial={false}\n              animate={{ scaleX: merged ? (LINE + LINE_GAP) / LINE : 1 }}\n              transition={t({ type: \"spring\", stiffness: 300, damping: 30, delay: merged ? 0.05 : 0 })}\n            >\n              <span className={cn(\"absolute inset-0 transition-colors duration-300\", char ? \"bg-foreground/40\" : \"bg-foreground/15\")} />\n              {isActive && (\n                <motion.span\n                  key={active}\n                  className=\"absolute inset-x-0 -top-px h-[2px] origin-left rounded-full bg-foreground\"\n                  initial={{ scaleX: 0 }}\n                  animate={{ scaleX: 1 }}\n                  transition={t({ duration: 0.3, ease: [0.22, 1, 0.36, 1] })}\n                />\n              )}\n              {status === \"loading\" && (\n                <motion.span\n                  className=\"absolute inset-x-0 -top-px h-[2px] bg-foreground/60\"\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: reduced ? 1 : [0, 1, 0] }}\n                  transition={reduced ? { duration: 0 } : { duration: 1.1, repeat: Infinity, delay: i * 0.08, ease: \"easeInOut\" }}\n                />\n              )}\n              <motion.span\n                className=\"absolute inset-x-0 -top-px h-[2px]\"\n                style={{ backgroundColor: toneColor }}\n                initial={false}\n                animate={{ opacity: state === \"idle\" ? 0 : 1 }}\n                transition={t({ duration: 0.3, delay: merged ? 0.1 : 0 })}\n              />\n            </motion.span>\n          </motion.div>\n        )\n      })}\n      <AnimatePresence>\n        {merged && (\n          <motion.span\n            aria-hidden=\"true\"\n            className=\"absolute inset-y-0 left-full ml-4 flex items-center\"\n            style={{ color: successColor }}\n            initial={{ opacity: 0, x: -4 }}\n            animate={{ opacity: 1, x: 0 }}\n            exit={{ opacity: 0 }}\n            transition={t({ duration: 0.3, delay: 0.5 })}\n          >\n            <Check className=\"size-6\" delay={0.55} instant={reduced} />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n\n  return (\n    <div className={cn(\"relative inline-flex\", disabled && \"opacity-50\", className)}>\n      {variant === \"line\" ? line : boxes}\n      <input\n        ref={inputRef}\n        type=\"text\"\n        value={code}\n        maxLength={length}\n        inputMode={pattern === \"numeric\" ? \"numeric\" : \"text\"}\n        autoComplete=\"one-time-code\"\n        autoCapitalize={pattern === \"numeric\" ? \"off\" : \"characters\"}\n        autoCorrect=\"off\"\n        spellCheck={false}\n        autoFocus={autoFocus}\n        disabled={disabled}\n        readOnly={locked}\n        aria-label={label}\n        aria-invalid={status === \"error\" || undefined}\n        className=\"absolute inset-0 z-10 size-full cursor-text appearance-none bg-transparent text-base text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed\"\n        onChange={(e) => handleInput(e.target.value, e.target.selectionStart)}\n        onPaste={(e) => {\n          e.preventDefault()\n          handleInput(e.clipboardData.getData(\"text\"))\n        }}\n        onSelect={(e) => setSel(e.currentTarget.selectionStart)}\n        onClick={(e) => {\n          const el = e.currentTarget\n          const rect = el.getBoundingClientRect()\n          const i = Math.min(Math.max(Math.floor(((e.clientX - rect.left) / rect.width) * length), 0), code.length)\n          el.setSelectionRange(i, Math.min(i + 1, code.length))\n          setSel(i)\n        }}\n        onFocus={() => setFocused(true)}\n        onBlur={() => setFocused(false)}\n      />\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {status === \"success\" ? \"Code verified\" : status === \"error\" ? \"Incorrect code, try again\" : status === \"loading\" ? \"Verifying code\" : \"\"}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/otp-input",
  "type": "registry:component"
}