{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ripple","type":"registry:ui","title":"Ripple","description":"Ink that spreads from where a surface is pressed. Drop it inside anything pressable; it listens to its parent. A held press keeps the ink until release, and Enter or Space start it from the centre.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/ripple.tsx","type":"registry:ui","target":"components/ui/ripple.tsx","content":"\"use client\";\nimport { useEffect, useRef, type ComponentProps } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./ripple.css\";\n\nexport type RippleProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  /** Turns the ripple off, for a disabled host. */\n  disabled?: boolean;\n};\n\n/**\n * Ink that spreads from where a surface is pressed. Place it last inside anything pressable that is positioned and\n * clips its overflow; it listens to its parent. A held press keeps the ink until release, Enter and Space start it\n * from the centre, and under reduced motion it is a brief tint instead.\n */\nexport function Ripple({ disabled = false, className, ...props }: RippleProps) {\n  const layer = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const ink = layer.current;\n    const host = ink?.parentElement;\n    if (!ink || !host || disabled) return;\n    const still = matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let held: { dot: HTMLElement; grown: Promise<unknown> } | null = null;\n\n    const start = (x: number, y: number) => {\n      const box = host.getBoundingClientRect();\n      const radius = Math.hypot(Math.max(x, box.width - x), Math.max(y, box.height - y));\n      const dot = document.createElement(\"span\");\n      dot.dataset.slot = \"ripple-ink\";\n      Object.assign(dot.style, { left: `${x - radius}px`, top: `${y - radius}px`, width: `${radius * 2}px`, height: `${radius * 2}px` });\n      ink.append(dot);\n      const grow = still.matches\n        ? dot.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 90, fill: \"forwards\" })\n        : dot.animate([{ transform: \"scale(0)\", opacity: 1 }, { transform: \"scale(1)\", opacity: 1 }], { duration: Math.min(560, 260 + radius), easing: \"cubic-bezier(.2,.7,.3,1)\", fill: \"forwards\" });\n      held = { dot, grown: grow.finished.catch(() => undefined) };\n    };\n    const end = () => {\n      const current = held;\n      held = null;\n      if (!current) return;\n      void current.grown.then(() => current.dot.animate([{ opacity: 1 }, { opacity: 0 }], { duration: still.matches ? 120 : 280, fill: \"forwards\" }).finished.then(() => current.dot.remove(), () => current.dot.remove()));\n    };\n    const down = (event: PointerEvent) => {\n      if (event.button !== 0) return;\n      const box = host.getBoundingClientRect();\n      end();\n      start(event.clientX - box.left, event.clientY - box.top);\n    };\n    const key = (event: KeyboardEvent) => {\n      if (event.repeat || (event.key !== \"Enter\" && event.key !== \" \") || event.target !== host) return;\n      end();\n      start(host.clientWidth / 2, host.clientHeight / 2);\n      if (event.key === \"Enter\") end();\n    };\n    const keyUp = (event: KeyboardEvent) => event.key === \" \" && end();\n\n    host.addEventListener(\"pointerdown\", down);\n    host.addEventListener(\"keydown\", key);\n    host.addEventListener(\"keyup\", keyUp);\n    for (const type of [\"pointerup\", \"pointerleave\", \"pointercancel\", \"blur\"]) host.addEventListener(type, end);\n    return () => {\n      host.removeEventListener(\"pointerdown\", down);\n      host.removeEventListener(\"keydown\", key);\n      host.removeEventListener(\"keyup\", keyUp);\n      for (const type of [\"pointerup\", \"pointerleave\", \"pointercancel\", \"blur\"]) host.removeEventListener(type, end);\n      ink.replaceChildren();\n    };\n  }, [disabled]);\n\n  return <span ref={layer} data-slot=\"ripple\" aria-hidden=\"true\" className={cn(\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\", className)} {...props} />;\n}\n"},{"path":"ui/ripple.css","type":"registry:file","target":"components/ui/ripple.css","content":"/* ripple.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Ripple: ink in the current colour, kept faint so the surface's own label stays the loudest thing. */\n  [data-slot=\"ripple\"] { z-index: 0; --ripple-strength: 14%; }\n  [data-slot=\"ripple\"] > [data-slot=\"ripple-ink\"] { position: absolute; border-radius: 50%; background: color-mix(in oklab, currentColor var(--ripple-strength), transparent); opacity: 0; pointer-events: none; }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"surfaces","docs":"/docs/components/ripple"}}