{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple",
  "title": "Ripple",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Concentric rings that breathe outward as a background, plus a press ripple that spreads from the pointer (or the center for keyboard) on any surface.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/ripple.tsx",
      "content": "// Ballmac UI: Ripple. https://ui.ballmac.com/components/ripple\n\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype RippleProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Number of rings. */\n  circles?: number\n  /** Diameter of the innermost ring in pixels. */\n  size?: number\n  /** Extra diameter added by each ring in pixels. */\n  gap?: number\n  /** Seconds for one slow breath of the rings. */\n  duration?: number\n}\n\n/** Concentric rings that breathe outward from the center of the nearest positioned parent. Decorative. */\nfunction Ripple({ circles = 7, size = 200, gap = 70, duration = 4, className, ...props }: RippleProps) {\n  const reduce = useReducedMotion()\n  return (\n    <div\n      data-slot=\"ripple\"\n      aria-hidden=\"true\"\n      className={cn(\"pointer-events-none absolute inset-0 flex items-center justify-center [mask-image:linear-gradient(to_bottom,black,transparent_90%)]\", className)}\n      {...props}\n    >\n      {Array.from({ length: circles }, (_, i) => {\n        const d = size + i * gap\n        return (\n          <motion.span\n            key={i}\n            className=\"absolute rounded-full border border-foreground/15 bg-foreground/[0.03]\"\n            style={{ width: d, height: d, opacity: Math.max(0.15, 1 - i * 0.14) }}\n            animate={reduce ? undefined : { scale: [1, 0.9, 1] }}\n            transition={{ duration, delay: i * 0.12, repeat: Infinity, ease: \"easeInOut\" }}\n          />\n        )\n      })}\n    </div>\n  )\n}\n\ntype RippleBurst = { id: number; x: number; y: number; size: number }\n\ntype ClickRippleProps = React.ComponentProps<\"div\"> & {\n  /** Seconds a ripple takes to spread and fade. */\n  duration?: number\n  /** Ripples start from the center of the element instead of the pointer. */\n  centered?: boolean\n}\n\n/** Wrap any surface so a press sends a ripple out from the pointer. Keyboard activation ripples from the center. */\nfunction ClickRipple({ duration = 0.7, centered = false, className, children, onPointerDown, onKeyDown, ...props }: ClickRippleProps) {\n  const reduce = useReducedMotion()\n  const ref = React.useRef<HTMLDivElement>(null)\n  const [bursts, setBursts] = React.useState<RippleBurst[]>([])\n  const next = React.useRef(0)\n\n  function spawn(clientX?: number, clientY?: number) {\n    const el = ref.current\n    if (!el || reduce) return\n    const box = el.getBoundingClientRect()\n    const x = centered || clientX === undefined ? box.width / 2 : clientX - box.left\n    const y = centered || clientY === undefined ? box.height / 2 : clientY - box.top\n    const size = Math.hypot(Math.max(x, box.width - x), Math.max(y, box.height - y)) * 2\n    setBursts((all) => [...all, { id: next.current++, x, y, size }])\n  }\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"click-ripple\"\n      onPointerDown={(e) => {\n        onPointerDown?.(e)\n        spawn(e.clientX, e.clientY)\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e)\n        if ((e.key === \"Enter\" || e.key === \" \") && e.target === e.currentTarget) spawn()\n      }}\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      {...props}\n    >\n      {children}\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-[inherit]\">\n        {bursts.map((b) => (\n          <motion.span\n            key={b.id}\n            className=\"absolute rounded-full bg-foreground/20\"\n            style={{ left: b.x - b.size / 2, top: b.y - b.size / 2, width: b.size, height: b.size }}\n            initial={{ scale: 0, opacity: 1 }}\n            animate={{ scale: 1, opacity: 0 }}\n            transition={{ duration, ease: \"easeOut\" }}\n            onAnimationComplete={() => setBursts((all) => all.filter((x) => x.id !== b.id))}\n          />\n        ))}\n      </span>\n    </div>\n  )\n}\n\nexport { Ripple, ClickRipple, type RippleProps, type ClickRippleProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/ripple.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ripple",
      "rings",
      "background",
      "click",
      "material"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<Ripple circles size gap duration /> fills its positioned parent. <ClickRipple centered duration> wraps a surface and ripples on pointer down, and from the center on Enter or Space when the wrapper itself has focus.",
      "whenToUse": [
        "A calm focal point behind a central call to action",
        "Touch feedback on custom cards and tiles"
      ],
      "whenNotToUse": [
        "Standard buttons (the Ballmac button already has press feedback)"
      ],
      "composesWith": [
        "card",
        "button",
        "orbiting-circles"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "All rings and ripples are aria-hidden and pointer-events-none"
        },
        {
          "keys": "Reduced motion",
          "action": "Rings are still and press ripples are skipped"
        }
      ],
      "customization": [
        "circles, size, gap",
        "duration",
        "ClickRipple centered"
      ]
    },
    "examples": [
      "ripple-demo",
      "ripple-click"
    ],
    "url": "https://ui.ballmac.com/components/ripple"
  },
  "categories": [
    "backgrounds"
  ],
  "type": "registry:ui"
}