{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-input-otp","type":"registry:component","title":"Input OTP (Motion)","description":"InputOTP with motion: each character pops into its box, the focus glides from box to box, an error shakes the row and a full code ripples.","dependencies":["motion"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/motion-field.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/field.json","https://dashral.com/r/input-otp.json"],"files":[{"path":"registry/dashral/motion/input-otp.tsx","type":"registry:file","target":"components/dashral/motion/input-otp.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, LayoutGroup, animate, motion } from \"motion/react\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldOwnProps } from \"../field\"\nimport type { InputOTPProps } from \"../input-otp\"\nimport { cn } from \"../lib/utils\"\n\nimport { motionMessages } from \"../lib/motion-field\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\n\nexport type { InputOTPProps } from \"../input-otp\"\n\nconst allowed = { digits: /[^0-9]/g, alphanumeric: /[^0-9a-zA-Z]/g }\n\n/**\n * The kit's InputOTP with motion: the same length, pattern, groups, mask and field props. Each character pops into its\n * box and shrinks out when deleted; the focus is one ring that glides from box to box as you type; an error shakes the\n * row (again each time it changes); and a full code ripples through the boxes, one after another. With reduced motion\n * characters only fade and nothing shakes or ripples.\n */\nexport function InputOTP(props: InputOTPProps) {\n  const [field, control] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field} renderMessages={motionMessages}>\n      <InputOTPControl {...control} error={props.error} />\n    </FieldWrapper>\n  )\n}\n\nfunction InputOTPControl({\n  length = 6,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onComplete,\n  pattern = \"digits\",\n  groups,\n  mask = false,\n  size = \"default\",\n  className,\n  classNames,\n  onFocus,\n  onBlur,\n  error,\n  ...rest\n}: Omit<InputOTPProps, Exclude<keyof FieldOwnProps, \"error\">>) {\n  const props = useFieldControl(rest)\n  const reduced = useReducedMotion()\n  const [own, setOwn] = React.useState(defaultValue)\n  const code = (value ?? own).slice(0, length)\n  const [focused, setFocused] = React.useState(false)\n  const row = React.useRef<HTMLDivElement>(null)\n  const group = React.useId()\n\n  // A full code ripples through the boxes; each time it's filled again, it ripples again.\n  const [ripples, setRipples] = React.useState(0)\n  function change(raw: string) {\n    const next = raw.replace(allowed[pattern], \"\").slice(0, length).toUpperCase()\n    if (next === code) return\n    if (value === undefined) setOwn(next)\n    onValueChange?.(next)\n    if (next.length === length) {\n      setRipples((n) => n + 1)\n      onComplete?.(next)\n    }\n  }\n\n  // An error (or a new one) shakes the row.\n  const errorKey = error ? (typeof error === \"string\" ? error : \"error\") : null\n  const shown = React.useRef(errorKey)\n  React.useEffect(() => {\n    if (shown.current === errorKey) return\n    shown.current = errorKey\n    if (errorKey === null || reduced || !row.current) return\n    const controls = animate(row.current, { x: [0, -7, 6, -4, 2, 0] }, { duration: 0.4, ease: \"easeOut\" })\n    return () => controls.stop()\n  }, [errorKey, reduced])\n\n  const active = Math.min(code.length, length - 1)\n  const runs = groups && groups.reduce((sum, n) => sum + n, 0) === length ? groups : [length]\n  let start = 0\n\n  return (\n    <div\n      ref={row}\n      data-slot=\"input-otp\"\n      data-disabled={props.disabled || undefined}\n      className={cn(\"group/otp relative flex w-fit items-center gap-2 has-disabled:opacity-50\", className)}\n    >\n      <input\n        {...props}\n        type=\"text\"\n        value={code}\n        onChange={(event) => change(event.target.value)}\n        onFocus={(event) => {\n          setFocused(true)\n          const end = event.currentTarget.value.length\n          event.currentTarget.setSelectionRange(end, end)\n          onFocus?.(event)\n        }}\n        onBlur={(event) => {\n          setFocused(false)\n          onBlur?.(event)\n        }}\n        onSelect={(event) => {\n          const end = event.currentTarget.value.length\n          if (event.currentTarget.selectionStart !== end) event.currentTarget.setSelectionRange(end, end)\n        }}\n        maxLength={length}\n        inputMode={pattern === \"digits\" ? \"numeric\" : \"text\"}\n        autoComplete=\"one-time-code\"\n        autoCapitalize=\"characters\"\n        spellCheck={false}\n        // Over the boxes, invisible: it takes the taps, the keys and what's pasted or filled in.\n        className=\"absolute inset-0 z-10 size-full cursor-text appearance-none bg-transparent text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed\"\n      />\n      <LayoutGroup id={group}>\n        {runs.map((run, i) => {\n          const from = start\n          start += run\n          return (\n            <React.Fragment key={i}>\n              {i > 0 && <span aria-hidden className=\"h-0.5 w-2 shrink-0 rounded-full bg-border\" />}\n              <div aria-hidden className={cn(\"flex gap-2\", classNames?.slots)}>\n                {Array.from({ length: run }, (_, j) => {\n                  const index = from + j\n                  const char = code[index]\n                  const current = focused && index === active\n                  return (\n                    <motion.div\n                      key={`${index}-${ripples}`}\n                      data-slot=\"input-otp-slot\"\n                      data-filled={char ? \"\" : undefined}\n                      className={cn(\n                        \"relative grid place-items-center rounded-md border border-input bg-transparent font-medium shadow-xs transition-[color,border-color] tabular-nums\",\n                        size === \"lg\" ? \"size-12 text-xl\" : \"size-10 text-lg\",\n                        \"group-has-[[aria-invalid=true]]/otp:border-destructive\",\n                        classNames?.slot\n                      )}\n                      // The ripple: each box lifts a little, one after another.\n                      initial={false}\n                      animate={ripples && !reduced ? { y: [0, -5, 0] } : undefined}\n                      transition={{ duration: duration.considered, ease: ease.standard, delay: index * 0.04 }}\n                    >\n                      {/* The focus: one ring gliding from box to box. */}\n                      {current && (\n                        <motion.span\n                          layoutId=\"otp-focus\"\n                          aria-hidden\n                          className=\"pointer-events-none absolute -inset-px rounded-[inherit] border border-ring ring-2 ring-ring/15 group-has-[[aria-invalid=true]]/otp:border-destructive group-has-[[aria-invalid=true]]/otp:ring-destructive/15\"\n                          transition={reduced ? { duration: 0 } : spring.move}\n                        />\n                      )}\n                      <AnimatePresence mode=\"popLayout\" initial={false}>\n                        {char ? (\n                          <motion.span\n                            key={char + index}\n                            className=\"col-start-1 row-start-1\"\n                            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5, y: 6 }}\n                            animate={{ opacity: 1, scale: 1, y: 0 }}\n                            exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5, transition: { duration: duration.fast, ease: ease.exit } }}\n                            transition={reduced ? { duration: duration.fast } : spring.pop}\n                          >\n                            {mask ? <span className=\"block size-2 rounded-full bg-current\" /> : char}\n                          </motion.span>\n                        ) : (\n                          current && (\n                            // The caret, blinking in the box that's next, while it's empty.\n                            <span key=\"caret\" className=\"col-start-1 row-start-1 h-5 w-px animate-pulse bg-foreground [animation-duration:1s] motion-reduce:animate-none\" />\n                          )\n                        )}\n                      </AnimatePresence>\n                    </motion.div>\n                  )\n                })}\n              </div>\n            </React.Fragment>\n          )\n        })}\n      </LayoutGroup>\n    </div>\n  )\n}\n"}]}