{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "click-ripple",
  "title": "Click Ripple",
  "description": "An animated click ripple for React and shadcn, a ring that spreads from the exact press point.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-demo-core.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/click-ripple/click-ripple.tsx",
      "content": "\"use client\";\n\nimport { motion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { DEMO_PRESS, localPoint, type DemoPoint, type DemoPressDetail } from \"../_primitives/demo-core/demo-core\";\nimport { duration, ease, reducedFade, smooth, useSafeReducedMotion } from \"../lib/motion\";\n\nexport type ClickRippleProps = {\n  /**\n   * Which presses ring: a real pointer press anywhere on the parent, a Demo Cursor's press on\n   * or inside it, or both. Default \"both\".\n   */\n  trigger?: \"pointer\" | \"demo\" | \"both\";\n  /** The ring's colour is `currentColor`; set it with a text class. Default `text-brand-text`. */\n  className?: string;\n};\n\ntype Ring = DemoPoint & { id: number };\n\n/** More than this many presses in flight and the oldest gives way, so a burst of clicks stays light. */\nconst MAX_RINGS = 4;\n/** The ring's full width in px: wider than a 44px hit target (rule 66), so it clears the control it pressed. */\nconst RING = 56;\n\n/**\n * A press ring for clicks you want seen: in product demos, recordings and tutorials. It rings\n * out from the exact press point: a soft disc marks the press and a 1.5px ring grows on the\n * smooth spring as it fades. Mount it inside any positioned element; it listens to that\n * element and draws over it without taking input. Under reduced motion the ring shows at\n * full size and fades, without growing.\n */\nexport function ClickRipple({ trigger = \"both\", className = \"text-brand-text\" }: ClickRippleProps) {\n  const reduced = useSafeReducedMotion();\n  const layer = useRef<HTMLSpanElement>(null);\n  const [rings, setRings] = useState<Ring[]>([]);\n  const next = useRef(0);\n\n  useEffect(() => {\n    const frame = layer.current;\n    const host = frame?.parentElement;\n    if (!frame || !host) return;\n    const ring = (clientX: number, clientY: number) => {\n      const id = ++next.current;\n      setRings((all) => [...all.slice(1 - MAX_RINGS), { id, ...localPoint(clientX, clientY, frame) }]);\n    };\n    const onPointer = (e: PointerEvent) => {\n      if (e.button === 0) ring(e.clientX, e.clientY);\n    };\n    const onDemo = (e: Event) => {\n      const { clientX, clientY } = (e as CustomEvent<DemoPressDetail>).detail;\n      ring(clientX, clientY);\n    };\n    if (trigger !== \"demo\") host.addEventListener(\"pointerdown\", onPointer);\n    if (trigger !== \"pointer\") host.addEventListener(DEMO_PRESS, onDemo);\n    return () => {\n      host.removeEventListener(\"pointerdown\", onPointer);\n      host.removeEventListener(DEMO_PRESS, onDemo);\n    };\n  }, [trigger]);\n\n  const done = (id: number) => setRings((all) => all.filter((r) => r.id !== id));\n\n  return (\n    <span ref={layer} aria-hidden className={`pointer-events-none absolute inset-0 z-2 ${className}`}>\n      {rings.map((r) => (\n        <span key={r.id} className=\"absolute size-0\" style={{ left: r.x, top: r.y }}>\n          {/* The press: a soft disc under the tip, gone before the ring has spread. */}\n          <motion.span\n            className=\"absolute -top-3 -left-3 size-6 rounded-pill bg-current\"\n            initial={{ opacity: 0.18, scale: reduced ? 1 : 0.6 }}\n            animate={{ opacity: 0, scale: 1 }}\n            transition={reduced ? { ...reducedFade, delay: duration.slow } : { opacity: { duration: duration.slow, ease: ease.exit }, scale: smooth }}\n          />\n          {/* The ring: grows on smooth with no overshoot and fades on its own clock, holding its\n              colour before it goes (ease-in). A surface-coloured halo under the stroke keeps it\n              legible over a filled button too. Sized inline, so a parent's `[&_svg]:size-4`\n              (Button's icon rule) cannot shrink it. */}\n          <motion.svg\n            viewBox=\"0 0 56 56\"\n            className=\"absolute overflow-visible\"\n            style={{ width: RING, height: RING, left: -RING / 2, top: -RING / 2 }}\n            initial={{ opacity: 1, scale: reduced ? 1 : 0.3 }}\n            animate={{ opacity: 0, scale: 1 }}\n            transition={reduced ? { ...reducedFade, delay: duration.slow } : { scale: smooth, opacity: { duration: duration.sheet, ease: ease.exit } }}\n            onAnimationComplete={() => done(r.id)}\n          >\n            <circle cx=\"28\" cy=\"28\" r=\"27\" fill=\"none\" className=\"stroke-surface\" strokeWidth=\"3.5\" vectorEffect=\"non-scaling-stroke\" />\n            <circle cx=\"28\" cy=\"28\" r=\"27\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" vectorEffect=\"non-scaling-stroke\" />\n          </motion.svg>\n        </span>\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/click-ripple/click-ripple.tsx"
    }
  ],
  "meta": {
    "details": "A press ring for clicks you want seen in demos, recordings and tutorials. A soft disc marks the press, then a 1.5px ring grows from that exact point on the smooth spring and fades. It rings at real presses on its parent and at a Demo Cursor's presses, so a scripted click and a real one look the same.",
    "industries": [
      "saas",
      "devtools",
      "ai"
    ],
    "releaseAt": "2026-10-26"
  },
  "docs": "Import ClickRipple from components/eased/click-ripple/click-ripple and mount it inside any positioned element. It rings at real pointer presses and at Demo Cursor presses inside that element.",
  "type": "registry:ui"
}