{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-input","type":"registry:component","title":"OTP Input","description":"One box per digit for a verification code, with paste and SMS autofill support and a shake for a wrong code.","author":"Ryan","dependencies":["clsx","lucide-react","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/otp-input.tsx","type":"registry:component","target":"@components/motion/otp-input.tsx","content":"\"use client\";\n// easeui.dev/components/motion/otp-input\n\nimport { Check, Loader2 } from \"lucide-react\";\nimport { type ClipboardEvent, type KeyboardEvent, useEffect, useRef, useState } from \"react\";\nimport { EASE_OUT_CSS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface OtpInputProps {\n  /** How many digits. Default 6. */\n  length?: number;\n  /** Controlled value. */\n  value?: string;\n  /** Starting value when uncontrolled. Default \"\". */\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Called once every box has a digit, before onVerify runs. */\n  onComplete?: (value: string) => void;\n  /**\n   * Runs once every box has a digit. Return (or resolve) true or false and the\n   * input shows a spinner below the boxes while it waits, then colors itself\n   * green or red from the result on its own. Leave it out to keep driving\n   * invalid/success yourself.\n   */\n  onVerify?: (value: string) => boolean | Promise<boolean>;\n  /** Shakes the boxes once and switches the ring to the destructive color. Ignored while onVerify is set. */\n  invalid?: boolean;\n  /** Switches the ring to the success color and shows a check below the boxes. Ignored while onVerify is set. */\n  success?: boolean;\n  disabled?: boolean;\n  /** Name for a hidden input, so the code submits with a plain HTML form. */\n  name?: string;\n  className?: string;\n}\n\n// A gentle nudge, not a rattle.\nconst SHAKE: Keyframe[] = [\n  { transform: \"translateX(0)\" },\n  { transform: \"translateX(-5px)\" },\n  { transform: \"translateX(4px)\" },\n  { transform: \"translateX(-2px)\" },\n  { transform: \"translateX(0)\" },\n];\n\nconst POP: Keyframe[] = [\n  { transform: \"scale(0.9)\", opacity: 0.6 },\n  { transform: \"scale(1)\", opacity: 1 },\n];\n\n// Spinner and check share a slot, so success morphs in place.\nconst SWAP = \"col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none\";\nconst SHOWN = \"scale-100 opacity-100\";\nconst HIDDEN = \"scale-50 opacity-0\";\n\nfunction prefersReducedMotion() {\n  return typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\n/**\n * A verification code as one box per digit. Typing advances to the next box,\n * Backspace steps back through empty ones, and pasting or an SMS autofill\n * spreads its digits across the remaining boxes. Marking it invalid gives the\n * row a short, gentle shake. Pass onVerify and the input runs the whole\n * check-and-color cycle itself, morphing its own spinner into a check.\n */\nexport function OtpInput({\n  length = 6,\n  value,\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  onVerify,\n  invalid = false,\n  success = false,\n  disabled = false,\n  name,\n  className,\n}: OtpInputProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const isControlled = value !== undefined;\n  const digits = (isControlled ? value : uncontrolled).slice(0, length);\n  const refs = useRef<(HTMLInputElement | null)[]>([]);\n  const rootRef = useRef<HTMLFieldSetElement>(null);\n  const statusRef = useRef<HTMLSpanElement>(null);\n  const prevDigits = useRef(digits);\n  const wasComplete = useRef(false);\n  const verifyToken = useRef(0);\n\n  // Auto status when onVerify is set; otherwise the props drive it.\n  const [status, setStatus] = useState<\"idle\" | \"checking\" | \"success\" | \"invalid\">(\"idle\");\n  const checking = Boolean(onVerify) && status === \"checking\";\n  const effectiveInvalid = onVerify ? status === \"invalid\" : invalid;\n  const effectiveSuccess = onVerify ? status === \"success\" : success;\n  const showStatus = checking || effectiveSuccess;\n\n  const setValue = (next: string) => {\n    const clipped = next.slice(0, length);\n    if (!isControlled) setUncontrolled(clipped);\n    onChange?.(clipped);\n    // Editing after a result starts the next attempt fresh.\n    if (onVerify && status !== \"idle\") setStatus(\"idle\");\n\n    const complete = clipped.length === length;\n    if (complete && !wasComplete.current) {\n      onComplete?.(clipped);\n      if (onVerify) {\n        verifyToken.current += 1;\n        const token = verifyToken.current;\n        setStatus(\"checking\");\n        Promise.resolve(onVerify(clipped))\n          .then((ok) => {\n            if (verifyToken.current === token) setStatus(ok ? \"success\" : \"invalid\");\n          })\n          .catch(() => {\n            if (verifyToken.current === token) setStatus(\"invalid\");\n          });\n      }\n    }\n    wasComplete.current = complete;\n  };\n\n  // Pop new digits, shake the row when invalid.\n  useEffect(() => {\n    if (prefersReducedMotion()) {\n      prevDigits.current = digits;\n      return;\n    }\n    const prev = prevDigits.current;\n    prevDigits.current = digits;\n    for (let index = 0; index < length; index += 1) {\n      if (digits[index] && digits[index] !== prev[index]) {\n        refs.current[index]?.animate(POP, { duration: 140, easing: EASE_OUT_CSS });\n      }\n    }\n  }, [digits, length]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!effectiveInvalid || !root || prefersReducedMotion()) return;\n    // Wait for the last pop to settle first, so the two don't overlap.\n    root.animate(SHAKE, { duration: 260, delay: 80, easing: \"ease-out\" });\n  }, [effectiveInvalid]);\n\n  // Fades the status row in once, on its first appearance only.\n  useEffect(() => {\n    const el = statusRef.current;\n    if (!showStatus || !el || prefersReducedMotion()) return;\n    el.animate([{ opacity: 0, transform: \"translateY(4px)\" }, { opacity: 1, transform: \"translateY(0)\" }], {\n      duration: 150,\n      easing: EASE_OUT_CSS,\n    });\n  }, [showStatus]);\n\n  const focusInput = (index: number) => refs.current[index]?.focus();\n\n  const onInputChange = (index: number, raw: string) => {\n    const chars = raw.replace(/\\D/g, \"\");\n    if (!chars) {\n      setValue(digits.slice(0, index) + digits.slice(index + 1));\n      return;\n    }\n    // One key replaces a box; a paste or autofill spreads across several.\n    const next = (digits.slice(0, index) + chars + digits.slice(index + 1)).slice(0, length);\n    setValue(next);\n    focusInput(Math.min(index + chars.length, length - 1));\n  };\n\n  const onKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Backspace\" && !digits[index] && index > 0) {\n      event.preventDefault();\n      setValue(digits.slice(0, index - 1) + digits.slice(index));\n      focusInput(index - 1);\n    } else if (event.key === \"ArrowLeft\" && index > 0) {\n      event.preventDefault();\n      focusInput(index - 1);\n    } else if (event.key === \"ArrowRight\" && index < length - 1) {\n      event.preventDefault();\n      focusInput(index + 1);\n    }\n  };\n\n  const onPaste = (index: number, event: ClipboardEvent<HTMLInputElement>) => {\n    event.preventDefault();\n    onInputChange(index, event.clipboardData.getData(\"text\"));\n  };\n\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      <fieldset ref={rootRef} className={cn(\"m-0 inline-flex gap-2 border-0 p-0\", className)}>\n        <legend className=\"sr-only\">Verification code</legend>\n        {Array.from({ length }, (_, index) => index).map((index) => (\n          <input\n            key={index}\n            ref={(el) => {\n              refs.current[index] = el;\n            }}\n            type=\"text\"\n            inputMode=\"numeric\"\n            autoComplete={index === 0 ? \"one-time-code\" : \"off\"}\n            maxLength={length}\n            disabled={disabled || checking}\n            aria-invalid={effectiveInvalid}\n            value={digits[index] ?? \"\"}\n            onChange={(event) => onInputChange(index, event.target.value)}\n            onKeyDown={(event) => onKeyDown(index, event)}\n            onPaste={(event) => onPaste(index, event)}\n            onFocus={(event) => event.target.select()}\n            className={cn(\n              \"h-12 w-10 rounded-xl bg-card text-center text-lg font-medium text-foreground shadow-[0_0_0_1px_var(--border-strong)] outline-none transition-shadow duration-150 ease-out\",\n              \"focus:shadow-[0_0_0_2px_var(--accent)]\",\n              effectiveSuccess\n                ? \"shadow-[0_0_0_2px_var(--success)]\"\n                : \"aria-invalid:shadow-[0_0_0_2px_var(--destructive)]\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\",\n            )}\n          />\n        ))}\n        {name ? <input type=\"hidden\" name={name} value={digits} /> : null}\n      </fieldset>\n      {showStatus ? (\n        <span ref={statusRef} role=\"status\" className=\"inline-flex items-center gap-2 text-xs font-medium\">\n          <span aria-hidden=\"true\" className=\"grid h-3.5 w-3.5 place-items-center\">\n            <Loader2\n              className={cn(SWAP, \"h-3.5 w-3.5 animate-spin text-muted-foreground\", checking ? SHOWN : HIDDEN)}\n            />\n            <Check\n              strokeWidth={3}\n              className={cn(SWAP, \"h-3.5 w-3.5 text-success\", checking ? HIDDEN : SHOWN)}\n            />\n          </span>\n          <span aria-hidden=\"true\" className=\"grid\">\n            <span className={cn(SWAP, \"text-muted-foreground\", checking ? SHOWN : HIDDEN)}>Verifying</span>\n            <span className={cn(SWAP, \"text-success\", checking ? HIDDEN : SHOWN)}>Verified</span>\n          </span>\n          <span className=\"sr-only\">{checking ? \"Verifying\" : \"Verified\"}</span>\n        </span>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders). Critically damped `useSpring` config,\n * so the value follows the pointer closely and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}