{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple",
  "type": "registry:ui",
  "title": "Ripple",
  "description": "A Material-style ink ripple that blooms from the exact point of contact, with a spring press-dip and rebound. Concurrent ripples stack; keyboard activation blooms from center.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/buttons/ripple/ripple.tsx",
      "type": "registry:ui",
      "target": "components/crucible/ripple.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, type HTMLMotionProps } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type RippleSize = \"sm\" | \"md\" | \"lg\";\n\nexport interface RippleProps extends React.ComponentPropsWithoutRef<\"button\"> {\n  /** Ripple ink color. Any CSS color; drawn at low opacity so it tints, never covers. @default soft white */\n  color?: string;\n  /** Padding / font scale of the button. @default \"md\" */\n  size?: RippleSize;\n  /** Peak opacity of the expanding ink, 0–1. Lower reads more restrained. @default 0.35 */\n  intensity?: number;\n  /** Seconds an ink drop takes to expand across the face and fade. @default 0.6 */\n  duration?: number;\n  /** Corner radius in px. @default 10 */\n  radius?: number;\n  /**\n   * Render the passed child as the root element instead of a `<button>`,\n   * merging in the ripple layer, press feedback, className, style and refs.\n   * The child must forward props to a real interactive element. @default false\n   */\n  asChild?: boolean;\n  /**\n   * Imperative handle — see {@link RippleHandle}. Note this is the handle, not\n   * the DOM node (Ripple never forwarded its element ref).\n   */\n  ref?: React.Ref<RippleHandle>;\n}\n\n/** Imperative controls exposed through the `ref` prop. */\nexport interface RippleHandle {\n  /**\n   * Bloom an ink drop without clicking: purely visual, fires no click and\n   * never moves focus. `x`/`y` are px within the button (default: center).\n   * Stacks with any drops already in flight.\n   */\n  pulse(x?: number, y?: number): void;\n}\n\n/** Concurrent drops are capped; the oldest is retired when a new one lands. */\nconst MAX_DROPS = 8;\n\ninterface Drop {\n  id: number;\n  /** Origin x within the button, in px. */\n  x: number;\n  /** Origin y within the button, in px. */\n  y: number;\n  /** Diameter that covers the whole face from this origin, in px. */\n  size: number;\n}\n\nconst SIZE_CLASSES: Record<RippleSize, string> = {\n  sm: \"px-4 py-2 text-xs gap-1.5\",\n  md: \"px-6 py-3 text-sm gap-2\",\n  lg: \"px-8 py-4 text-base gap-2.5\",\n};\n\n/** Merge any number of refs (callback or object) into one callback ref. */\nfunction mergeRefs<T>(...refs: Array<React.Ref<T> | undefined>): React.RefCallback<T> {\n  return (value) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(value);\n      else if (ref) (ref as React.MutableRefObject<T | null>).current = value;\n    }\n  };\n}\n\n/** Compose two event handlers; the injected one runs first, then the child's own. */\nfunction composeHandlers<E>(\n  theirs: ((event: E) => void) | undefined,\n  ours: (event: E) => void\n): (event: E) => void {\n  return (event) => {\n    ours(event);\n    theirs?.(event);\n  };\n}\n\n/**\n * Ripple — a Material-style ink ripple. On press a soft circle blooms from the\n * exact point of contact and fades; several presses stack their own concurrent\n * ripples (capped at 8). The face dips on a spring while held and rebounds on release.\n * Keyboard activation (Enter/Space) blooms from the center. Neutral-elegant by\n * default with a single `color` accent; `size` and `intensity` are the knobs.\n * Under prefers-reduced-motion the expanding bloom is replaced by one quiet,\n * full-face opacity flash — motion-free, never blank.\n */\nexport function Ripple({\n  color = \"rgba(255,255,255,0.9)\",\n  size = \"md\",\n  intensity = 0.35,\n  duration = 0.6,\n  radius = 10,\n  asChild = false,\n  className,\n  style,\n  disabled,\n  children,\n  onPointerDown,\n  onPointerUp,\n  onPointerLeave,\n  onClick,\n  ref,\n  ...rest\n}: RippleProps) {\n  const reducedMotion = useReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const [drops, setDrops] = React.useState<Drop[]>([]);\n  const [pressed, setPressed] = React.useState(false);\n  const nextId = React.useRef(0);\n\n  const timers = React.useRef(new Set<number>());\n  React.useEffect(() => {\n    const set = timers.current;\n    return () => {\n      for (const t of set) window.clearTimeout(t);\n      set.clear();\n    };\n  }, []);\n\n  /** Add one drop at (x, y) in button-local px; null = center. */\n  const bloom = React.useCallback(\n    (lx: number | null, ly: number | null) => {\n      const el = rootRef.current;\n      if (!el) return;\n      // Layout size, not the transformed rect — the face may be mid press-dip.\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      const x = lx ?? w / 2;\n      const y = ly ?? h / 2;\n      // Diameter that reaches the farthest corner, so the ink always fills.\n      const dia = Math.hypot(Math.max(x, w - x), Math.max(y, h - y)) * 2;\n\n      const id = nextId.current++;\n      setDrops((prev) => {\n        const next = [...prev, { id, x, y, size: dia }];\n        return next.length > MAX_DROPS ? next.slice(next.length - MAX_DROPS) : next;\n      });\n      const t = window.setTimeout(\n        () => {\n          timers.current.delete(t);\n          setDrops((prev) => prev.filter((d) => d.id !== id));\n        },\n        // Keep the node past its animation (1.1x) so the fade completes.\n        (reducedMotion ? 0.36 : duration) * 1100\n      );\n      timers.current.add(t);\n    },\n    [duration, reducedMotion]\n  );\n\n  const spawn = React.useCallback(\n    (event: React.MouseEvent<HTMLElement>) => {\n      const el = rootRef.current;\n      if (!el) return;\n      // Keyboard-activated clicks report detail 0 and no real pointer position —\n      // bloom from the exact center in that case.\n      if (event.detail === 0) return bloom(null, null);\n      const rect = el.getBoundingClientRect();\n      // Map through any press-dip scale so the drop lands under the finger.\n      const sx = rect.width / (el.offsetWidth || rect.width || 1);\n      const sy = rect.height / (el.offsetHeight || rect.height || 1);\n      bloom((event.clientX - rect.left) / sx, (event.clientY - rect.top) / sy);\n    },\n    [bloom]\n  );\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      pulse(x?: number, y?: number) {\n        if (disabled) return;\n        bloom(x ?? null, y ?? null);\n      },\n    }),\n    [bloom, disabled]\n  );\n\n  const handleClick = (event: React.MouseEvent<HTMLElement>) => {\n    if (!disabled) spawn(event);\n    onClick?.(event as React.MouseEvent<HTMLButtonElement>);\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLElement>) => {\n    if (!disabled) setPressed(true);\n    onPointerDown?.(event as React.PointerEvent<HTMLButtonElement>);\n  };\n  const release = () => setPressed(false);\n  const handlePointerUp = (event: React.PointerEvent<HTMLElement>) => {\n    release();\n    onPointerUp?.(event as React.PointerEvent<HTMLButtonElement>);\n  };\n  const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {\n    release();\n    onPointerLeave?.(event as React.PointerEvent<HTMLButtonElement>);\n  };\n\n  const pressTransition = reducedMotion\n    ? { duration: 0.12, ease: \"easeOut\" as const }\n    : { type: \"spring\" as const, stiffness: 420, damping: 24 };\n\n  const rootClassName = cn(\n    \"relative isolate inline-flex select-none items-center justify-center overflow-hidden rounded-[inherit]\",\n    \"border border-neutral-800 bg-neutral-900 font-medium text-white\",\n    \"transition-colors duration-200 hover:border-neutral-700 hover:bg-neutral-800\",\n    \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n    \"focus-visible:ring-[color:var(--ripple-color)]\",\n    \"disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50\",\n    SIZE_CLASSES[size],\n    className\n  );\n\n  const rootStyle = {\n    borderRadius: radius,\n    \"--ripple-color\": color,\n    ...style,\n  } as React.CSSProperties;\n\n  // The ink layer + label, shared by both the <button> and asChild paths.\n  const inkLayer = (\n    <span\n      aria-hidden\n      className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\"\n    >\n      <AnimatePresence>\n        {drops.map((drop) =>\n          reducedMotion ? (\n            // Reduced motion: a single quiet full-face flash, pinned to an\n            // explicit resting pose (opacity 0) so it never freezes visible.\n            <motion.span\n              key={drop.id}\n              className=\"absolute inset-0 rounded-[inherit]\"\n              style={{ background: color }}\n              initial={{ opacity: intensity * 0.55 }}\n              animate={{ opacity: 0 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.36, ease: \"easeOut\" }}\n            />\n          ) : (\n            <motion.span\n              key={drop.id}\n              className=\"absolute rounded-full\"\n              style={{\n                left: drop.x,\n                top: drop.y,\n                translateX: \"-50%\",\n                translateY: \"-50%\",\n                background: `radial-gradient(circle, ${color} 0%, ${color} 58%, transparent 72%)`,\n              }}\n              initial={{ width: 0, height: 0, opacity: intensity }}\n              animate={{ width: drop.size, height: drop.size, opacity: 0 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration, ease: [0.16, 0.6, 0.32, 1] }}\n            />\n          )\n        )}\n      </AnimatePresence>\n    </span>\n  );\n\n  const scale = pressed && !disabled && !reducedMotion ? 0.96 : 1;\n\n  if (asChild) {\n    const child = React.Children.only(children) as React.ReactElement<\n      React.HTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLElement> }\n    >;\n    const childProps = child.props;\n    return React.cloneElement(\n      child,\n      {\n        // Forward the rest of Ripple's own props (aria-*, data-*, handlers…);\n        // the child's same-named props still win.\n        ...Object.fromEntries(Object.entries(rest).filter(([key]) => !(key in childProps))),\n        \"aria-disabled\": disabled || undefined,\n        ref: mergeRefs(rootRef, (child as unknown as { ref?: React.Ref<HTMLElement> }).ref),\n        className: cn(rootClassName, childProps.className),\n        style: {\n          ...rootStyle,\n          ...childProps.style,\n          transform: `scale(${scale})`,\n          transition: reducedMotion\n            ? \"transform 0.12s ease-out\"\n            : \"transform 0.42s cubic-bezier(0.34,1.56,0.64,1)\",\n        },\n        onClick: composeHandlers(childProps.onClick, handleClick),\n        onPointerDown: composeHandlers(childProps.onPointerDown, handlePointerDown),\n        onPointerUp: composeHandlers(childProps.onPointerUp, handlePointerUp),\n        onPointerLeave: composeHandlers(childProps.onPointerLeave, handlePointerLeave),\n      } as React.HTMLAttributes<HTMLElement> & { ref: React.Ref<HTMLElement> },\n      <>\n        {inkLayer}\n        <span className=\"relative z-10 inline-flex items-center justify-center gap-[inherit]\">\n          {childProps.children}\n        </span>\n      </>\n    );\n  }\n\n  return (\n    <motion.button\n      ref={rootRef as React.Ref<HTMLButtonElement>}\n      data-crucible=\"ripple\"\n      className={rootClassName}\n      style={rootStyle}\n      disabled={disabled}\n      animate={{ scale }}\n      transition={pressTransition}\n      onClick={handleClick}\n      onPointerDown={handlePointerDown}\n      onPointerUp={handlePointerUp}\n      onPointerLeave={handlePointerLeave}\n      {...(rest as unknown as HTMLMotionProps<\"button\">)}\n    >\n      {inkLayer}\n      <span className=\"relative z-10 inline-flex items-center justify-center gap-[inherit]\">\n        {children}\n      </span>\n    </motion.button>\n  );\n}\n"
    }
  ]
}