{
  "name": "otp-input-ripple",
  "type": "registry:ui",
  "tier": "free",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "files": [
    {
      "path": "components/craftui/ui/otp-input/ripple.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\nimport { RippleBorder, RippleBorderHandle } from \"./ripple-border\";\n\nexport interface RippleOtpHandle {\n  /** Drive the result animation manually when validating outside `validate`. */\n  notifyResult: (result: \"valid\" | \"invalid\") => void;\n  /** Clear digits, colors and focus the first box. */\n  reset: () => void;\n}\n\nexport type CaretShape = \"bar\" | \"block\" | \"underscore\";\n\ninterface RippleOtpProps {\n  /** Number of boxes. */\n  length?: number;\n  /** Controlled value — pair with `onChange`. Omit for uncontrolled usage. */\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Fires once every time all boxes become filled, before validation. */\n  onComplete?: (value: string) => void;\n  /**\n   * Called with the full code on completion. Return (or resolve) `true` for\n   * success, `false` for failure — the component runs the pending ring while\n   * a promise is in flight and choreographs the result animation itself.\n   */\n  validate?: (value: string) => boolean | Promise<boolean>;\n  /** Fires after the success animation finishes. */\n  onSuccess?: (value: string) => void;\n  /** Fires after the failure animation finishes. */\n  onFailure?: (value: string) => void;\n  /** Clear digits and refocus the first box after a failure. */\n  autoClearOnFailure?: boolean;\n  /** Animate the border on every keystroke. Off by default — the ripple only plays for the pending / success / failure states. */\n  rippleOnType?: boolean;\n  /** Show a low-amplitude oscillating ring while an async validate is pending. Off by default — pending is indicated by border color only. */\n  pendingRing?: boolean;\n  /** Render digits as dots. */\n  masked?: boolean;\n  /** Which characters the boxes accept. */\n  mode?: \"numeric\" | \"alphanumeric\";\n  disabled?: boolean;\n  autoFocus?: boolean;\n  /** Success wave timing — delay between each box's green pulse, in ms. */\n  staggerMs?: number;\n  /** Corner radius in px, shared by the box and its ripple outline. */\n  radius?: number;\n  strokeWidth?: number;\n  /** Ripple height ceiling in px. */\n  maxAmplitude?: number;\n  /** How localized the ripple bulge is (fraction of the perimeter). */\n  bumpWidth?: number;\n  /** Ripple settle speed — higher is snappier. */\n  decay?: number;\n  /** Failure shake travel in px. */\n  shakeDistance?: number;\n  caretColor?: string;\n  caretShape?: CaretShape;\n  /** Accessible name for the group. */\n  label?: string;\n  className?: string;\n}\n\nconst SHAKE_DURATION_MS = 280; // dur-a * 2 + dur-b * 2, keep in sync with ri-shake\nconst FAILURE_SETTLE_MS = 620;\nconst SUCCESS_SETTLE_MS = 500;\n\nexport const RippleOtp = forwardRef<RippleOtpHandle, RippleOtpProps>(\n  function RippleOtp(\n    {\n      length = 6,\n      value: valueProp,\n      defaultValue = \"\",\n      onChange,\n      onComplete,\n      validate,\n      onSuccess,\n      onFailure,\n      autoClearOnFailure = true,\n      rippleOnType = false,\n      pendingRing = false,\n      masked = false,\n      mode = \"numeric\",\n      disabled = false,\n      autoFocus = false,\n      staggerMs = 70,\n      radius = 14,\n      strokeWidth = 1.5,\n      maxAmplitude = 5,\n      bumpWidth = 0.1,\n      decay = 0.06,\n      shakeDistance = 6,\n      caretColor = \"#3B82F6\",\n      caretShape = \"bar\",\n      label = \"One-time password\",\n      className = \"\",\n    },\n    ref,\n  ) {\n    const lineRefs = useRef<(RippleBorderHandle | null)[]>([]);\n    const inputRefs = useRef<(HTMLInputElement | null)[]>([]);\n    const containerRef = useRef<HTMLDivElement>(null);\n    const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n\n    const [internalValue, setInternalValue] = useState(defaultValue);\n    const isControlled = valueProp !== undefined;\n    const value = isControlled ? valueProp : internalValue;\n\n    const [focusedIndex, setFocusedIndex] = useState<number | null>(null);\n    const [status, setStatus] = useState<\n      \"idle\" | \"pending\" | \"success\" | \"failure\"\n    >(\"idle\");\n    const [announcement, setAnnouncement] = useState(\"\");\n    const statusRef = useRef(status);\n    useEffect(() => {\n      statusRef.current = status;\n    }, [status]);\n    // Guards the complete → validate flow from firing twice for the same code\n    // (6th keystroke + Enter, or a paste that re-fills the same value).\n    const lastCompletedCode = useRef<string | null>(null);\n\n    const digits = Array.from({ length }, (_, i) => value[i] ?? \"\");\n    const locked = disabled || status === \"pending\";\n\n    const filterChars = useCallback(\n      (raw: string) =>\n        mode === \"numeric\"\n          ? raw.replace(/[^0-9]/g, \"\")\n          : raw.replace(/[^a-zA-Z0-9]/g, \"\").toUpperCase(),\n      [mode],\n    );\n\n    const clearTimers = () => {\n      timers.current.forEach(clearTimeout);\n      timers.current = [];\n    };\n    const after = (ms: number, fn: () => void) => {\n      timers.current.push(setTimeout(fn, ms));\n    };\n\n    useEffect(() => clearTimers, []);\n\n    const setValue = (next: string) => {\n      if (!isControlled) setInternalValue(next);\n      onChange?.(next);\n    };\n\n    const resetField = useCallback(() => {\n      clearTimers();\n      lineRefs.current.forEach((line) => line?.setColorState(\"idle\"));\n      containerRef.current?.classList.remove(\"is-shaking\");\n      lastCompletedCode.current = null;\n      setStatus(\"idle\");\n      setAnnouncement(\"\");\n    }, []);\n\n    const runSuccess = useCallback(\n      (code: string) => {\n        clearTimers();\n        setStatus(\"success\");\n        setAnnouncement(\"Code accepted.\");\n        lineRefs.current.forEach((line, i) => {\n          after(i * staggerMs, () => {\n            line?.setColorState(\"success\");\n            line?.pulse({ from: 0, to: 1, magnitude: 9 });\n          });\n        });\n        after((length - 1) * staggerMs + SUCCESS_SETTLE_MS, () =>\n          onSuccess?.(code),\n        );\n      },\n      [length, staggerMs, onSuccess],\n    );\n\n    const runFailure = useCallback(\n      (code: string) => {\n        clearTimers();\n        setStatus(\"failure\");\n        setAnnouncement(\"Code incorrect. Try again.\");\n        lineRefs.current.forEach((line) => {\n          line?.setColorState(\"destructive\");\n          line?.pulseDouble({ from: 0.5, to: 0.5, magnitude: 10 });\n        });\n        const field = containerRef.current;\n        if (field) {\n          field.classList.remove(\"is-shaking\");\n          // Force a reflow so re-adding the class restarts the animation.\n          void field.offsetWidth;\n          field.classList.add(\"is-shaking\");\n          after(SHAKE_DURATION_MS, () => field.classList.remove(\"is-shaking\"));\n        }\n        after(FAILURE_SETTLE_MS, () => {\n          if (autoClearOnFailure) {\n            setValue(\"\");\n            lastCompletedCode.current = null;\n            lineRefs.current.forEach((line) => line?.setColorState(\"idle\"));\n            setStatus(\"idle\");\n            inputRefs.current[0]?.focus();\n          }\n          onFailure?.(code);\n        });\n      },\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      [autoClearOnFailure, onFailure],\n    );\n\n    const handleComplete = useCallback(\n      (code: string) => {\n        if (statusRef.current === \"pending\") return;\n        if (lastCompletedCode.current === code) return;\n        lastCompletedCode.current = code;\n        onComplete?.(code);\n\n        if (!validate) return;\n        const outcome = validate(code);\n        if (typeof outcome === \"boolean\") {\n          if (outcome) runSuccess(code);\n          else runFailure(code);\n          return;\n        }\n        setStatus(\"pending\");\n        setAnnouncement(\"Checking code…\");\n        lineRefs.current.forEach((line) => {\n          line?.setColorState(\"pending\");\n          if (pendingRing) line?.startIdle();\n        });\n        outcome\n          .then((ok) => {\n            lineRefs.current.forEach((line) => line?.stopIdle());\n            if (ok) runSuccess(code);\n            else runFailure(code);\n          })\n          .catch(() => {\n            lineRefs.current.forEach((line) => line?.stopIdle());\n            runFailure(code);\n          });\n      },\n      [onComplete, validate, pendingRing, runSuccess, runFailure],\n    );\n\n    useImperativeHandle(ref, () => ({\n      notifyResult: (result) => {\n        const code = digits.join(\"\");\n        if (result === \"valid\") runSuccess(code);\n        else runFailure(code);\n      },\n      reset: () => {\n        resetField();\n        setValue(\"\");\n        inputRefs.current[0]?.focus();\n      },\n    }));\n\n    /** Writes `chars` into the boxes starting at `index` — shared by typing, paste and SMS autofill. */\n    const fillFrom = (index: number, chars: string) => {\n      const next = digits.slice();\n      let cursor = index;\n      for (const ch of chars) {\n        if (cursor >= length) break;\n        next[cursor] = ch;\n        cursor++;\n      }\n      const joined = next.join(\"\").slice(0, length);\n      setValue(joined);\n\n      const focusIndex = Math.min(cursor, length - 1);\n      inputRefs.current[focusIndex]?.focus();\n\n      if (joined.length === length && !next.includes(\"\")) {\n        handleComplete(joined);\n      }\n    };\n\n    const handleChange = (\n      index: number,\n      e: React.ChangeEvent<HTMLInputElement>,\n    ) => {\n      if (locked) return;\n      if (statusRef.current !== \"idle\") resetField();\n      const chars = filterChars(e.target.value);\n\n      // SMS autofill / paste lands as a multi-char change — distribute it.\n      if (chars.length > 1) {\n        fillFrom(index, chars);\n        return;\n      }\n\n      const digit = chars.slice(-1);\n      if (rippleOnType) {\n        lineRefs.current[index]?.setColorState(\"primary\");\n        lineRefs.current[index]?.nudge(digit ? 3 : -3);\n      }\n\n      const next = digits.slice();\n      next[index] = digit;\n      const joined = next.join(\"\").slice(0, length);\n      setValue(joined);\n\n      if (digit && index < length - 1) {\n        if (rippleOnType) {\n          // ripple travels toward the box's right edge, handing off to the next box\n          lineRefs.current[index]?.pulse({ from: 0.05, to: 0.5, magnitude: 5 });\n        }\n        inputRefs.current[index + 1]?.focus();\n      }\n      if (digit && !next.includes(\"\")) handleComplete(joined);\n    };\n\n    const handlePaste = (\n      index: number,\n      e: React.ClipboardEvent<HTMLInputElement>,\n    ) => {\n      e.preventDefault();\n      if (locked) return;\n      if (statusRef.current !== \"idle\") resetField();\n      const chars = filterChars(e.clipboardData.getData(\"text\"));\n      if (!chars) return;\n      // A full-length code always fills from the first box, regardless of\n      // which box received the paste.\n      fillFrom(chars.length >= length ? 0 : index, chars);\n    };\n\n    const handleKeyDown = (\n      index: number,\n      e: React.KeyboardEvent<HTMLInputElement>,\n    ) => {\n      if (locked) return;\n      if (e.key === \"Backspace\" && !digits[index] && index > 0) {\n        inputRefs.current[index - 1]?.focus();\n      } else if (e.key === \"ArrowLeft\" && index > 0) {\n        e.preventDefault();\n        inputRefs.current[index - 1]?.focus();\n      } else if (e.key === \"ArrowRight\" && index < length - 1) {\n        e.preventDefault();\n        inputRefs.current[index + 1]?.focus();\n      } else if (e.key === \"Home\") {\n        e.preventDefault();\n        inputRefs.current[0]?.focus();\n      } else if (e.key === \"End\") {\n        e.preventDefault();\n        inputRefs.current[length - 1]?.focus();\n      } else if (e.key === \"Enter\") {\n        e.preventDefault();\n        const code = digits.join(\"\");\n        if (code.length === length) handleComplete(code);\n      }\n    };\n\n    const showFakeCaret = caretShape !== \"bar\";\n\n    return (\n      <div\n        ref={containerRef}\n        role=\"group\"\n        aria-label={`${label}, ${length} characters`}\n        className={`ri-field flex gap-2 ${className}`}\n        style={\n          {\n            \"--shake-distance\": `${shakeDistance}px`,\n            \"--ri-caret-color\": caretColor,\n          } as React.CSSProperties\n        }\n      >\n        {digits.map((digit, i) => (\n          <div key={i} className=\"relative w-11\">\n            <input\n              ref={(el) => {\n                inputRefs.current[i] = el;\n              }}\n              type=\"text\"\n              inputMode={mode === \"numeric\" ? \"numeric\" : \"text\"}\n              autoComplete={i === 0 ? \"one-time-code\" : \"off\"}\n              autoFocus={autoFocus && i === 0}\n              disabled={disabled}\n              aria-label={`Character ${i + 1} of ${length}`}\n              aria-invalid={status === \"failure\" || undefined}\n              value={masked && digit ? \"•\" : digit}\n              onChange={(e) => handleChange(i, e)}\n              onKeyDown={(e) => handleKeyDown(i, e)}\n              onPaste={(e) => handlePaste(i, e)}\n              onFocus={(e) => {\n                setFocusedIndex(i);\n                e.target.select();\n              }}\n              onBlur={() => setFocusedIndex((f) => (f === i ? null : f))}\n              className={[\n                \"bg-input/30 w-11 border border-transparent px-0 py-2.5 text-center\",\n                \"text-foreground text-lg font-medium outline-none\",\n                \"transition-shadow duration-150\",\n                \"focus-visible:ring-ring/50 focus-visible:ring-[1px]\",\n                \"disabled:opacity-50\",\n              ].join(\" \")}\n              style={{\n                borderRadius: radius,\n                caretColor: showFakeCaret ? \"transparent\" : caretColor,\n              }}\n            />\n            {showFakeCaret && focusedIndex === i && (\n              <span\n                // Remounts when the digit changes so the blink restarts solid,\n                // the way a native caret does.\n                key={`caret-${digit}`}\n                aria-hidden=\"true\"\n                className={`ri-caret ri-caret-${caretShape}`}\n                style={\n                  caretShape === \"block\"\n                    ? { borderRadius: radius - 4 }\n                    : undefined\n                }\n              />\n            )}\n            <RippleBorder\n              ref={(el) => {\n                lineRefs.current[i] = el;\n              }}\n              radius={radius}\n              strokeWidth={strokeWidth}\n              maxAmplitude={maxAmplitude}\n              bumpWidth={bumpWidth}\n              decay={decay}\n            />\n          </div>\n        ))}\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {announcement}\n        </span>\n      </div>\n    );\n  },\n);\n",
      "type": "registry:ui",
      "target": "~/components/craftui/ui/otp-input/ripple.tsx"
    },
    {
      "path": "components/craftui/ui/otp-input/ripple-border.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useEffect, useImperativeHandle, useRef } from \"react\";\nimport { useReducedMotion } from \"motion/react\";\n\nexport type RippleColorState =\n  | \"idle\"\n  | \"primary\"\n  | \"success\"\n  | \"destructive\"\n  | \"pending\";\n\nexport interface RippleBorderHandle {\n  /** Fire a scripted, decaying ripple that travels around the box's own outline. `from`/`to` are 0..1 fractions of the perimeter. */\n  pulse: (opts?: { from?: number; to?: number; magnitude?: number }) => void;\n  /** Two ripples in quick succession — a sharper \"wrong answer\" bounce, held in place. */\n  pulseDouble: (opts?: {\n    from?: number;\n    to?: number;\n    magnitude?: number;\n  }) => void;\n  /** Live per-keystroke nudge — accumulates into the current envelope. */\n  nudge: (dy: number) => void;\n  /** Non-decaying low-amplitude ring, for an async \"checking…\" state. */\n  startIdle: (magnitude?: number) => void;\n  /** Lets the idle ring decay back to flat. */\n  stopIdle: () => void;\n  setColorState: (state: RippleColorState) => void;\n}\n\ninterface RippleBorderProps {\n  className?: string;\n  /** Corner radius in px — should match the input's own rounded-* class. */\n  radius?: number;\n  /** Border stroke width in px. */\n  strokeWidth?: number;\n  /** Ripple height ceiling in px — pulses and nudges clamp to this. */\n  maxAmplitude?: number;\n  /** How localized the bulge is, as a fraction of the perimeter (0.05 tight … 0.25 wide). */\n  bumpWidth?: number;\n  /** Envelope decay per frame (0.02 lingering … 0.2 snappy). */\n  decay?: number;\n}\n\nconst SAMPLES = 96;\nconst IDLE_AMPLITUDE = 1.5;\nconst TIME_STEP = 0.4;\nconst DRIFT_RATE = 0.045;\nconst STOP_THRESHOLD = 0.03;\nconst DOUBLE_PULSE_GAP_MS = 90;\nconst DEFAULT_X = 0.12; // resting bulge position, along the top edge\n\nconst COLOR_VAR: Record<Exclude<RippleColorState, \"idle\">, string> = {\n  primary: \"var(--primary)\",\n  success: \"var(--success, oklch(0.723 0.219 149.579))\",\n  destructive: \"var(--destructive)\",\n  pending: \"var(--muted-foreground)\",\n};\n\ntype Seg =\n  | {\n      kind: \"line\";\n      x0: number;\n      y0: number;\n      x1: number;\n      y1: number;\n      len: number;\n      nx: number;\n      ny: number;\n    }\n  | {\n      kind: \"arc\";\n      cx: number;\n      cy: number;\n      r: number;\n      a0: number;\n      a1: number;\n      len: number;\n    };\n\nfunction buildPerimeter(w: number, h: number, radius: number) {\n  const r = Math.max(Math.min(radius, w / 2, h / 2), 0);\n  const straightW = Math.max(w - 2 * r, 0);\n  const straightH = Math.max(h - 2 * r, 0);\n  const cornerArc = (Math.PI / 2) * r;\n  const segs: Seg[] = [\n    { kind: \"line\", x0: r, y0: 0, x1: w - r, y1: 0, len: straightW, nx: 0, ny: -1 },\n    { kind: \"arc\", cx: w - r, cy: r, r, a0: -90, a1: 0, len: cornerArc },\n    { kind: \"line\", x0: w, y0: r, x1: w, y1: h - r, len: straightH, nx: 1, ny: 0 },\n    { kind: \"arc\", cx: w - r, cy: h - r, r, a0: 0, a1: 90, len: cornerArc },\n    { kind: \"line\", x0: w - r, y0: h, x1: r, y1: h, len: straightW, nx: 0, ny: 1 },\n    { kind: \"arc\", cx: r, cy: h - r, r, a0: 90, a1: 180, len: cornerArc },\n    { kind: \"line\", x0: 0, y0: h - r, x1: 0, y1: r, len: straightH, nx: -1, ny: 0 },\n    { kind: \"arc\", cx: r, cy: r, r, a0: 180, a1: 270, len: cornerArc },\n  ];\n  const total = segs.reduce((sum, s) => sum + s.len, 0) || 1;\n  return { segs, total };\n}\n\nfunction pointAt(segs: Seg[], total: number, sFrac: number) {\n  let d = (((sFrac % 1) + 1) % 1) * total;\n  for (const seg of segs) {\n    if (d <= seg.len || seg === segs[segs.length - 1]) {\n      const t = seg.len === 0 ? 0 : d / seg.len;\n      if (seg.kind === \"line\") {\n        return {\n          x: seg.x0 + (seg.x1 - seg.x0) * t,\n          y: seg.y0 + (seg.y1 - seg.y0) * t,\n          nx: seg.nx,\n          ny: seg.ny,\n        };\n      }\n      const angle = ((seg.a0 + (seg.a1 - seg.a0) * t) * Math.PI) / 180;\n      return {\n        x: seg.cx + seg.r * Math.cos(angle),\n        y: seg.cy + seg.r * Math.sin(angle),\n        nx: Math.cos(angle),\n        ny: Math.sin(angle),\n      };\n    }\n    d -= seg.len;\n  }\n  const first = segs[0] as Extract<Seg, { kind: \"line\" }>;\n  return { x: first.x0, y: first.y0, nx: first.nx, ny: first.ny };\n}\n\nexport const RippleBorder = forwardRef<RippleBorderHandle, RippleBorderProps>(\n  function RippleBorder(\n    {\n      className = \"\",\n      radius = 14,\n      strokeWidth = 1.5,\n      maxAmplitude = 5,\n      bumpWidth = 0.1,\n      decay = 0.06,\n    },\n    ref,\n  ) {\n    const svgRef = useRef<SVGSVGElement>(null);\n    const pathRef = useRef<SVGPathElement>(null);\n    const geometry = useRef<{ segs: Seg[]; total: number } | null>(null);\n\n    // Tunables live in a ref so the rAF loop always reads the latest prop\n    // values without re-creating the loop on every render.\n    const cfg = useRef({ maxAmplitude, bumpWidth, decay });\n    useEffect(() => {\n      cfg.current = { maxAmplitude, bumpWidth, decay };\n    }, [maxAmplitude, bumpWidth, decay]);\n\n    const envelope = useRef(0);\n    const time = useRef(Math.PI / 2);\n    const xPos = useRef(DEFAULT_X);\n    const xTarget = useRef(DEFAULT_X);\n    const idleMode = useRef(false);\n    const colorState = useRef<RippleColorState>(\"idle\");\n    const lastValue = useRef(0);\n    const rafId = useRef<number | null>(null);\n    const doubleTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n    const reducedMotion = useReducedMotion();\n\n    const lerp = (a: number, b: number, t: number) => a * (1 - t) + b * t;\n\n    const draw = (value: number) => {\n      const path = pathRef.current;\n      const geo = geometry.current;\n      if (!path || !geo) return;\n      lastValue.current = value;\n      const { maxAmplitude: maxAmp, bumpWidth: bw } = cfg.current;\n\n      let d = \"\";\n      for (let i = 0; i <= SAMPLES; i++) {\n        const s = i / SAMPLES;\n        let dist = Math.abs(s - xPos.current);\n        if (dist > 0.5) dist = 1 - dist;\n        const falloff = Math.exp(-(dist * dist) / (2 * bw * bw));\n        const pt = pointAt(geo.segs, geo.total, s);\n        const bulge = value * falloff;\n        const x = pt.x + pt.nx * bulge;\n        const y = pt.y + pt.ny * bulge;\n        d += `${i === 0 ? \"M\" : \"L\"}${x.toFixed(2)},${y.toFixed(2)} `;\n      }\n      d += \"Z\";\n      path.setAttribute(\"d\", d);\n\n      const magnitude = Math.min(Math.abs(value) / maxAmp, 1);\n      const baseOpacity = colorState.current === \"idle\" ? 0.35 : 0.55;\n      path.style.opacity = String(Math.min(baseOpacity + magnitude * 0.45, 1));\n      path.style.stroke =\n        colorState.current === \"idle\"\n          ? \"currentColor\"\n          : COLOR_VAR[colorState.current];\n    };\n\n    const stopLoop = () => {\n      if (rafId.current !== null) {\n        cancelAnimationFrame(rafId.current);\n        rafId.current = null;\n      }\n      if (doubleTimeout.current !== null) {\n        clearTimeout(doubleTimeout.current);\n        doubleTimeout.current = null;\n      }\n    };\n\n    const decayLoop = () => {\n      const value = envelope.current * Math.sin(time.current);\n      if (!idleMode.current) {\n        envelope.current = lerp(envelope.current, 0, cfg.current.decay);\n        xPos.current = lerp(xPos.current, xTarget.current, DRIFT_RATE);\n      }\n      time.current += TIME_STEP;\n      draw(value);\n\n      if (idleMode.current || Math.abs(envelope.current) > STOP_THRESHOLD) {\n        rafId.current = requestAnimationFrame(decayLoop);\n      } else {\n        envelope.current = 0;\n        time.current = Math.PI / 2;\n        xTarget.current = DEFAULT_X;\n        draw(0);\n        rafId.current = null;\n      }\n    };\n\n    const firePulse = (magnitude: number, fromX: number, toX: number) => {\n      idleMode.current = false;\n      envelope.current = magnitude;\n      time.current = Math.PI / 2; // sin() starts at 1 — immediate snap outward\n      xPos.current = fromX;\n      xTarget.current = toX;\n      if (rafId.current === null) {\n        rafId.current = requestAnimationFrame(decayLoop);\n      }\n    };\n\n    /** Reduced-motion fallback: instant, non-oscillating shape + color change. */\n    const instantState = (magnitude: number) => {\n      stopLoop();\n      idleMode.current = false;\n      envelope.current = 0;\n      time.current = Math.PI / 2;\n      xPos.current = DEFAULT_X;\n      xTarget.current = DEFAULT_X;\n      const bow =\n        Math.min(Math.abs(magnitude), cfg.current.maxAmplitude) * 0.35;\n      draw(magnitude === 0 ? 0 : bow);\n    };\n\n    useImperativeHandle(ref, () => ({\n      pulse: (opts = {}) => {\n        const magnitude = opts.magnitude ?? cfg.current.maxAmplitude;\n        if (reducedMotion) {\n          instantState(magnitude);\n          return;\n        }\n        stopLoop();\n        firePulse(magnitude, opts.from ?? 0, opts.to ?? 1);\n      },\n      pulseDouble: (opts = {}) => {\n        const magnitude = opts.magnitude ?? cfg.current.maxAmplitude * 1.2;\n        if (reducedMotion) {\n          instantState(magnitude);\n          return;\n        }\n        stopLoop();\n        const from = opts.from ?? DEFAULT_X;\n        const to = opts.to ?? DEFAULT_X;\n        firePulse(magnitude, from, to);\n        doubleTimeout.current = setTimeout(() => {\n          firePulse(magnitude, from, to);\n          doubleTimeout.current = null;\n        }, DOUBLE_PULSE_GAP_MS);\n      },\n      nudge: (dy) => {\n        if (reducedMotion) {\n          instantState(envelope.current + dy);\n          return;\n        }\n        idleMode.current = false;\n        envelope.current = Math.min(\n          Math.max(envelope.current + dy, -cfg.current.maxAmplitude),\n          cfg.current.maxAmplitude,\n        );\n        time.current = Math.PI / 2;\n        if (rafId.current === null) {\n          rafId.current = requestAnimationFrame(decayLoop);\n        }\n      },\n      startIdle: (magnitude = IDLE_AMPLITUDE) => {\n        if (reducedMotion) {\n          instantState(magnitude);\n          return;\n        }\n        stopLoop();\n        idleMode.current = true;\n        envelope.current = magnitude;\n        time.current = Math.PI / 2;\n        rafId.current = requestAnimationFrame(decayLoop);\n      },\n      stopIdle: () => {\n        idleMode.current = false;\n      },\n      setColorState: (state) => {\n        colorState.current = state;\n        draw(lastValue.current);\n      },\n    }));\n\n    useEffect(() => {\n      const svg = svgRef.current;\n      if (!svg) return;\n      const ro = new ResizeObserver((entries) => {\n        const entry = entries[0];\n        if (!entry) return;\n        const { width, height } = entry.contentRect;\n        if (width <= 0 || height <= 0) return;\n        geometry.current = buildPerimeter(width, height, radius);\n        draw(lastValue.current);\n      });\n      ro.observe(svg);\n      return () => {\n        ro.disconnect();\n        stopLoop();\n      };\n    }, [radius]);\n\n    return (\n      <svg\n        ref={svgRef}\n        aria-hidden=\"true\"\n        className={`pointer-events-none absolute inset-0 h-full w-full overflow-visible ${className}`}\n      >\n        <path\n          ref={pathRef}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={strokeWidth}\n          strokeLinejoin=\"round\"\n          className=\"text-neutral-300 dark:text-neutral-700 transition-[stroke,opacity] duration-150 ease-out\"\n        />\n      </svg>\n    );\n  },\n);\n",
      "type": "registry:ui",
      "target": "~/components/craftui/ui/otp-input/ripple-border.tsx"
    }
  ],
  "css": {
    ".ri-field": {
      "--shake-distance": "6px",
      "--shake-overshoot": "4px",
      "--shake-dur-a": "80ms",
      "--shake-dur-b": "60ms",
      "--shake-ease": "cubic-bezier(0.22, 1, 0.36, 1)"
    },
    ".ri-field.is-shaking": {
      "animation": "ri-shake calc(var(--shake-dur-a) * 2 + var(--shake-dur-b) * 2) linear"
    },
    "@keyframes ri-shake": {
      "0%": {
        "transform": "translateX(0)",
        "animation-timing-function": "var(--shake-ease)"
      },
      "28.57%": {
        "transform": "translateX(var(--shake-distance))",
        "animation-timing-function": "var(--shake-ease)"
      },
      "57.14%": {
        "transform": "translateX(calc(var(--shake-distance) * -1))",
        "animation-timing-function": "var(--shake-ease)"
      },
      "78.57%": {
        "transform": "translateX(var(--shake-overshoot))",
        "animation-timing-function": "var(--shake-ease)"
      },
      "100%": {
        "transform": "translateX(0)"
      }
    },
    ".ri-caret": {
      "position": "absolute",
      "pointer-events": "none",
      "background": "var(--ri-caret-color, #3B82F6)",
      "animation": "ri-caret-blink 1.1s steps(2, jump-none) infinite"
    },
    ".ri-caret-block": {
      "--ri-caret-max": "0.25",
      "inset": "4px"
    },
    ".ri-caret-underscore": {
      "--ri-caret-max": "1",
      "left": "22%",
      "right": "22%",
      "bottom": "7px",
      "height": "2px",
      "border-radius": "1px"
    },
    "@keyframes ri-caret-blink": {
      "0%, 49%": {
        "opacity": "var(--ri-caret-max, 1)"
      },
      "50%, 100%": {
        "opacity": "0"
      }
    },
    "@media (prefers-reduced-motion: reduce)": {
      ".ri-field": {
        "animation": "none !important",
        "transform": "none !important"
      },
      ".ri-caret": {
        "animation": "none",
        "opacity": "var(--ri-caret-max, 1)"
      }
    }
  },
  "cssVars": {
    "light": {
      "success": "oklch(0.723 0.219 149.579)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)"
    }
  },
  "meta": {
    "displayName": "OTP Input - Ripple",
    "description": "Each box wears an SVG outline that behaves like a liquid surface — typing nudges it, moving to the next box hands the ripple off toward its right edge. Pass a validate function and the component choreographs the pending ring, the staggered success wave and the red shake by itself, then calls your onSuccess / onFailure.",
    "category": "input",
    "tags": [
      "otp",
      "input",
      "verification",
      "2fa",
      "one-time-code",
      "ripple",
      "svg",
      "caret",
      "form"
    ],
    "animationType": "ripple"
  }
}