{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ripple","type":"registry:ui","title":"Ripple","description":"Touch feedback from the pointer origin.","dependencies":["motion"],"categories":["action feedback"],"docs":"https://www.interior.dev/docs/ripple","files":[{"path":"registry/interior/ripple.tsx","type":"registry:ui","target":"components/interior/ripple.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst BLOOM = { duration: 0.5, ease: \"linear\" } as const;\nconst BASE = 40;\n\nexport type RippleSpec = {\n  id: number;\n  x: number;\n  y: number;\n  scale: number;\n  released: boolean;\n};\n\nexport type UseRippleOptions = {\n  disabled?: boolean;\n  max?: number;\n  minVisible?: number;\n  fade?: number;\n};\n\nexport function useRipple({\n  disabled = false,\n  max = 4,\n  minVisible = 220,\n  fade = 320,\n}: UseRippleOptions = {}) {\n  const [ripples, setRipples] = useState<RippleSpec[]>([]);\n\n  const list = useRef<RippleSpec[]>([]);\n  const seq = useRef(0);\n  const born = useRef(new Map<number, number>());\n  const timers = useRef(new Map<number, ReturnType<typeof setTimeout>[]>());\n  const pointers = useRef(new Map<number, number>());\n  const keyed = useRef<number | null>(null);\n\n  const commit = useCallback((next: RippleSpec[]) => {\n    list.current = next;\n    setRipples(next);\n  }, []);\n\n  const forget = useCallback((id: number) => {\n    timers.current.get(id)?.forEach(clearTimeout);\n    timers.current.delete(id);\n    born.current.delete(id);\n  }, []);\n\n  const spawn = useCallback(\n    (el: HTMLElement, clientX?: number, clientY?: number) => {\n      const rect = el.getBoundingClientRect();\n      const x = Math.round(\n        clientX === undefined ? rect.width / 2 : clientX - rect.left,\n      );\n      const y = Math.round(\n        clientY === undefined ? rect.height / 2 : clientY - rect.top,\n      );\n      const reach = Math.max(\n        Math.hypot(x, y),\n        Math.hypot(rect.width - x, y),\n        Math.hypot(x, rect.height - y),\n        Math.hypot(rect.width - x, rect.height - y),\n      );\n\n      let next = list.current;\n      while (next.length >= max) {\n        forget(next[0].id);\n        next = next.slice(1);\n      }\n\n      const id = (seq.current += 1);\n      born.current.set(id, performance.now());\n      commit([\n        ...next,\n        {\n          id,\n          x,\n          y,\n          scale: Math.round((reach * 200) / BASE) / 100,\n          released: false,\n        },\n      ]);\n      return id;\n    },\n    [commit, forget, max],\n  );\n\n  const release = useCallback(\n    (id: number) => {\n      if (timers.current.has(id)) return;\n      if (!list.current.some((r) => r.id === id)) return;\n\n      const wait = Math.max(\n        0,\n        minVisible - (performance.now() - (born.current.get(id) ?? 0)),\n      );\n\n      const start = setTimeout(() => {\n        commit(\n          list.current.map((r) => (r.id === id ? { ...r, released: true } : r)),\n        );\n      }, wait);\n\n      const drop = setTimeout(() => {\n        forget(id);\n        commit(list.current.filter((r) => r.id !== id));\n      }, wait + fade);\n\n      timers.current.set(id, [start, drop]);\n    },\n    [commit, fade, forget, minVisible],\n  );\n\n  const releaseAll = useCallback(() => {\n    pointers.current.forEach((id) => release(id));\n    pointers.current.clear();\n    if (keyed.current !== null) {\n      release(keyed.current);\n      keyed.current = null;\n    }\n  }, [release]);\n\n  const endPointer = useCallback(\n    (pointerId: number) => {\n      const id = pointers.current.get(pointerId);\n      if (id === undefined) return;\n      pointers.current.delete(pointerId);\n      release(id);\n    },\n    [release],\n  );\n\n  useEffect(() => {\n    const bail = () => releaseAll();\n    const onVisibility = () => document.hidden && releaseAll();\n    window.addEventListener(\"blur\", bail);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      window.removeEventListener(\"blur\", bail);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [releaseAll]);\n\n  useEffect(() => {\n    const pending = timers.current;\n    return () => {\n      pending.forEach((set) => set.forEach(clearTimeout));\n      pending.clear();\n    };\n  }, []);\n\n  const bind = {\n    onPointerDown: (e: React.PointerEvent<HTMLElement>) => {\n      if (disabled) return;\n      if (e.pointerType === \"mouse\" && e.button !== 0) return;\n      if (pointers.current.has(e.pointerId)) return;\n      e.currentTarget.setPointerCapture?.(e.pointerId);\n      pointers.current.set(\n        e.pointerId,\n        spawn(e.currentTarget, e.clientX, e.clientY),\n      );\n    },\n    onPointerUp: (e: React.PointerEvent<HTMLElement>) => endPointer(e.pointerId),\n    onPointerCancel: (e: React.PointerEvent<HTMLElement>) =>\n      endPointer(e.pointerId),\n    onLostPointerCapture: (e: React.PointerEvent<HTMLElement>) =>\n      endPointer(e.pointerId),\n    onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {\n      if (disabled || e.repeat || keyed.current !== null) return;\n      if (e.key !== \" \" && e.key !== \"Enter\") return;\n      keyed.current = spawn(e.currentTarget);\n    },\n    onKeyUp: (e: React.KeyboardEvent<HTMLElement>) => {\n      if (keyed.current === null) return;\n      if (e.key !== \" \" && e.key !== \"Enter\" && e.key !== \"Escape\") return;\n      release(keyed.current);\n      keyed.current = null;\n    },\n    onBlur: () => releaseAll(),\n  };\n\n  return { bind, ripples, fadeDuration: fade / 1000 };\n}\n\nexport type RippleProps = {\n  children: React.ReactNode;\n  onPress?: () => void;\n  disabled?: boolean;\n  max?: number;\n  tintClassName?: string;\n  className?: string;\n};\n\nexport function Ripple({\n  children,\n  onPress,\n  disabled = false,\n  max = 4,\n  tintClassName = \"bg-stone-800/15 dark:bg-white/20\",\n  className = \"\",\n}: RippleProps) {\n  const { bind, ripples, fadeDuration } = useRipple({ disabled, max });\n  const reduced = useReducedMotion();\n\n  return (\n    <button\n      type=\"button\"\n      disabled={disabled}\n      onClick={onPress}\n      style={{ touchAction: \"manipulation\", WebkitTapHighlightColor: \"transparent\" }}\n      className={`relative isolate inline-flex select-none items-center justify-center gap-2 rounded-[9px] border border-stone-200 bg-white px-3.5 py-2 text-[13px] font-medium text-stone-700 outline-none focus-visible:ring-2 focus-visible:ring-stone-400 disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:focus-visible:ring-white/25 ${className}`}\n      {...bind}\n    >\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\"\n      >\n        {ripples.map((r) => (\n          <motion.span\n            key={r.id}\n            className={`absolute block rounded-full ${tintClassName}`}\n            style={{\n              left: r.x - BASE / 2,\n              top: r.y - BASE / 2,\n              width: BASE,\n              height: BASE,\n              willChange: \"transform, opacity\",\n            }}\n            initial={{ scale: reduced ? r.scale : 0, opacity: 0 }}\n            animate={{ scale: r.scale, opacity: r.released ? 0 : 1 }}\n            transition={{\n              scale: reduced ? { duration: 0 } : BLOOM,\n              opacity: {\n                duration: r.released ? fadeDuration : 0.07,\n                ease: r.released ? EASE : \"linear\",\n              },\n            }}\n          />\n        ))}\n      </span>\n\n      <span className=\"relative\">{children}</span>\n    </button>\n  );\n}\n"}]}