{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple-button",
  "type": "registry:ui",
  "title": "Ripple Button",
  "description": "A button that sends a circle out from wherever it was pressed, sized to reach the furthest corner.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/ripple-button.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface RippleButtonProps extends React.ComponentProps<\"button\"> {\n  /** Milliseconds one ripple takes to cross the button and fade out. */\n  duration?: number\n  /** Colour of the ripple. Keep it translucent so the label stays readable. */\n  color?: string\n}\n\ninterface Ripple {\n  id: number\n  x: number\n  y: number\n  size: number\n}\n\n/**\n * The ripple is a circle scaled up from nothing, not a growing width, so a\n * press costs one composited layer and no layout.\n */\nexport function RippleButton({\n  children,\n  className,\n  duration = 280,\n  color = \"color-mix(in oklch, currentColor 22%, transparent)\",\n  onPointerDown,\n  onKeyDown,\n  disabled,\n  style,\n  ...props\n}: RippleButtonProps) {\n  const [ripples, setRipples] = React.useState<Ripple[]>([])\n  const nextId = React.useRef(0)\n\n  // Measured from the press point to the furthest corner, so the circle always\n  // finishes covering the button no matter where it started.\n  const spawn = React.useCallback(\n    (node: HTMLButtonElement, clientX?: number, clientY?: number) => {\n      if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n        return\n      }\n\n      const rect = node.getBoundingClientRect()\n      const x = clientX === undefined ? rect.width / 2 : clientX - rect.left\n      const y = clientY === undefined ? rect.height / 2 : clientY - rect.top\n      const size =\n        2 *\n        Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y))\n\n      setRipples((current) => [\n        ...current,\n        { id: nextId.current++, x, y, size },\n      ])\n    },\n    []\n  )\n\n  const drop = React.useCallback((id: number) => {\n    setRipples((current) => current.filter((ripple) => ripple.id !== id))\n  }, [])\n\n  return (\n    <button\n      data-slot=\"ripple-button\"\n      disabled={disabled}\n      onPointerDown={(event) => {\n        onPointerDown?.(event)\n        if (!disabled) {\n          spawn(event.currentTarget, event.clientX, event.clientY)\n        }\n      }}\n      // Enter and Space press a button without a pointer ever touching it, so\n      // the keyboard gets its own ripple from the middle rather than none.\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        const isPress = event.key === \"Enter\" || event.key === \" \"\n        if (isPress && !event.repeat && !disabled) {\n          spawn(event.currentTarget)\n        }\n      }}\n      className={cn(\n        \"relative isolate inline-flex items-center justify-center overflow-hidden select-none\",\n        // The ripple says the press landed. The give says it landed on this.\n        \"transition-transform duration-150 ease-[var(--ease-out-quart)] active:scale-[0.97] motion-reduce:transition-none\",\n        className\n      )}\n      style={style}\n      {...props}\n    >\n      {ripples.map((ripple) => (\n        <span\n          key={ripple.id}\n          aria-hidden=\"true\"\n          onAnimationEnd={() => drop(ripple.id)}\n          className=\"animate-ripple-expand pointer-events-none absolute -z-10 rounded-full motion-reduce:hidden\"\n          style={\n            {\n              left: ripple.x - ripple.size / 2,\n              top: ripple.y - ripple.size / 2,\n              width: ripple.size,\n              height: ripple.size,\n              background: color,\n              \"--ripple-duration\": `${duration}ms`,\n            } as React.CSSProperties\n          }\n        />\n      ))}\n      {children}\n    </button>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "ease-out-quart": "cubic-bezier(0.165, 0.84, 0.44, 1)",
      "animate-ripple-expand": "ripple-expand var(--ripple-duration, 280ms) var(--ease-out-quart) forwards"
    }
  },
  "css": {
    "@keyframes ripple-expand": {
      "from": {
        "transform": "scale(0)",
        "opacity": "1"
      },
      "to": {
        "transform": "scale(1)",
        "opacity": "0"
      }
    }
  }
}