{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ripple-button","type":"registry:ui","title":"RippleButton","description":"Button that answers each click with a ripple of light from the click point, a soft glow and two fine rings spreading out like a wave; on hover a glow follows the pointer with a faint ring pulsing from it","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["buttons"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/ripple-button/RippleButton.tsx","content":"\"use client\";\n\nimport { forwardRef, useRef, useState } from \"react\";\nimport type {\n\tButtonHTMLAttributes,\n\tCSSProperties,\n\tMouseEvent,\n\tPointerEvent,\n\tReactNode,\n} from \"react\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport { useSoundFeedback } from \"../../sound/sound-feedback\";\nimport { cn } from \"../../utils\";\nimport \"./ripple-button.css\";\n\nexport interface RippleButtonProps extends Omit<\n\tButtonHTMLAttributes<HTMLButtonElement>,\n\t\"className\"\n> {\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/** Color of the ripple: its glow, its rings and the border tint */\n\trippleColor?: string;\n\t/** Animation duration in milliseconds */\n\tduration?: number;\n\t/** Button content */\n\tchildren?: ReactNode;\n\t/**\n\t * Plays the `hover` cue on mouse/pen pointer enter and the `press` cue on\n\t * click. Off by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\ninterface Ripple {\n\tx: number;\n\ty: number;\n\tsize: number;\n\tkey: number;\n}\n\n/**\n * Where a ripple starts and how big it grows: from the pointer, or from the\n * centre for a keyboard press (no pointer position), with a diameter that\n * reaches the farthest corner.\n */\nexport function rippleGeometry(\n\trect: { left: number; top: number; width: number; height: number },\n\tevent: { clientX: number; clientY: number; detail: number }\n): { x: number; y: number; size: number } {\n\tconst keyboard = event.detail === 0;\n\tconst cx = keyboard ? rect.width / 2 : event.clientX - rect.left;\n\tconst cy = keyboard ? rect.height / 2 : event.clientY - rect.top;\n\tconst dx = Math.max(cx, rect.width - cx);\n\tconst dy = Math.max(cy, rect.height - cy);\n\tconst size = 2 * Math.hypot(dx, dy);\n\treturn { x: cx - size / 2, y: cy - size / 2, size };\n}\n\nexport const RippleButton = forwardRef<HTMLButtonElement, RippleButtonProps>(\n\t(\n\t\t{\n\t\t\tclassName,\n\t\t\trippleColor = \"#60a5fa\",\n\t\t\tduration = 900,\n\t\t\tchildren,\n\t\t\tonClick,\n\t\t\tonPointerMove,\n\t\t\tsound = false,\n\t\t\t...restProps\n\t\t},\n\t\tforwardedRef\n\t) => {\n\t\tconst buttonRef = useRef<HTMLButtonElement | null>(null);\n\t\t// Hover cue for mouse/pen pointers only. `on: {}` binds nothing when sound is\n\t\t// off (and never the default click → press, which `handleClick` already plays).\n\t\tconst soundRef = useSoundFeedback({\n\t\t\ton: sound ? { pointerenter: \"hover\" } : {},\n\t\t\tdisabled: restProps.disabled,\n\t\t});\n\t\t// One composed callback whose identity changes only when the incoming\n\t\t// ref does — a click pushes and later drops a ripple, and a consumer's\n\t\t// callback ref must not be detached and re-attached on those renders.\n\t\tconst composedRef = useComposedRefs(forwardedRef, buttonRef, soundRef);\n\t\tconst [ripples, setRipples] = useState<Ripple[]>([]);\n\t\t// A monotonic counter, not a wall-clock stamp: two clicks landing in the\n\t\t// same millisecond would otherwise mint the same key, and the removal\n\t\t// filter below would then drop both ripples on the first timeout.\n\t\tconst nextRippleKey = useRef(0);\n\n\t\tconst playCue = useSoundCue(sound);\n\n\t\tfunction handleClick(event: MouseEvent<HTMLButtonElement>) {\n\t\t\t// The native `disabled` attribute already blocks real interaction,\n\t\t\t// but a synthetic event dispatched straight at the element — as a\n\t\t\t// test does — walks past that guard, so the cue repeats it.\n\t\t\tif (!restProps.disabled) playCue(\"press\");\n\t\t\tcreateRipple(event);\n\t\t\t// Call the original onClick handler if provided\n\t\t\tonClick?.(event);\n\t\t}\n\n\t\t// The hover glow follows the pointer: two CSS variables, no re-render.\n\t\tfunction handlePointerMove(event: PointerEvent<HTMLButtonElement>) {\n\t\t\tconst el = event.currentTarget;\n\t\t\tconst rect = el.getBoundingClientRect();\n\t\t\tel.style.setProperty(\"--ripple-x\", `${event.clientX - rect.left}px`);\n\t\t\tel.style.setProperty(\"--ripple-y\", `${event.clientY - rect.top}px`);\n\t\t\tonPointerMove?.(event);\n\t\t}\n\n\t\tfunction createRipple(event: MouseEvent<HTMLButtonElement>) {\n\t\t\tconst button = buttonRef.current;\n\t\t\tif (!button) return;\n\n\t\t\tconst { x, y, size } = rippleGeometry(button.getBoundingClientRect(), event);\n\t\t\tconst newRipple: Ripple = { x, y, size, key: nextRippleKey.current++ };\n\t\t\tsetRipples((prev) => [...prev, newRipple]);\n\n\t\t\t// Remove ripple after animation completes\n\t\t\tsetTimeout(() => {\n\t\t\t\tsetRipples((prev) => prev.filter((r) => r.key !== newRipple.key));\n\t\t\t}, duration);\n\t\t}\n\n\t\treturn (\n\t\t\t<button\n\t\t\t\tref={composedRef}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"ripple-button relative isolate flex cursor-pointer items-center justify-center overflow-hidden\",\n\t\t\t\t\t\"bg-background text-foreground border-border rounded-lg border px-5 py-2.5 text-center font-medium\",\n\t\t\t\t\t\"shadow-[inset_0_1px_0_rgb(255_255_255/0.06),0_1px_2px_rgb(0_0_0/0.08)]\",\n\t\t\t\t\t\"duration-300 ease-out transition-[border-color,background-color] motion-safe:transition-[transform,border-color,background-color]\",\n\t\t\t\t\t\"hover:bg-muted/40 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50\",\n\t\t\t\t\tclassName\n\t\t\t\t)}\n\t\t\t\tstyle={\n\t\t\t\t\t{\n\t\t\t\t\t\t\"--ripple-duration\": `${duration}ms`,\n\t\t\t\t\t\t\"--ripple-color\": rippleColor,\n\t\t\t\t\t} as CSSProperties\n\t\t\t\t}\n\t\t\t\tdata-rippling={ripples.length > 0 ? \"\" : undefined}\n\t\t\t\tonClick={handleClick}\n\t\t\t\tonPointerMove={handlePointerMove}\n\t\t\t\t{...restProps}\n\t\t\t>\n\t\t\t\t<div className=\"relative z-10\">{children}</div>\n\n\t\t\t\t{/* Hover: a soft glow under the pointer, and a faint ring that keeps pulsing from it */}\n\t\t\t\t<span className=\"ripple-hover\" aria-hidden=\"true\" />\n\n\t\t\t\t<span className=\"pointer-events-none absolute inset-0\" aria-hidden=\"true\">\n\t\t\t\t\t{ripples.map((ripple) => (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tkey={ripple.key}\n\t\t\t\t\t\t\tclassName=\"ripple-animation absolute rounded-full\"\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\twidth: `${ripple.size}px`,\n\t\t\t\t\t\t\t\theight: `${ripple.size}px`,\n\t\t\t\t\t\t\t\ttop: `${ripple.y}px`,\n\t\t\t\t\t\t\t\tleft: `${ripple.x}px`,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t</span>\n\t\t\t</button>\n\t\t);\n\t}\n);\n\nRippleButton.displayName = \"RippleButton\";\n","type":"registry:ui","target":"@ui/fancy/components/ripple-button/RippleButton.tsx"},{"path":"react/src/components/ripple-button/index.ts","content":"\"use client\";\n\nexport { RippleButton } from \"./RippleButton\";\nexport type { RippleButtonProps } from \"./RippleButton\";\n","type":"registry:ui","target":"@ui/fancy/components/ripple-button/index.ts"},{"path":"react/src/components/ripple-button/ripple-button.css","content":"/* Every rule is anchored on the root class `ripple-button` (the Svelte source\n   names it on the root; its compiler scoped the inner selectors). As plain CSS,\n   a bare `.ripple-hover` / `.ripple-animation` would leak into any consumer\n   element of that name. See PORTING.md, styling rule 2. */\n\n/* Hover: a fingertip over water. */\n.ripple-button .ripple-hover {\n\tposition: absolute;\n\tinset: 0;\n\tpointer-events: none;\n\topacity: 0;\n\tbackground: radial-gradient(\n\t\t80px circle at var(--ripple-x, 50%) var(--ripple-y, 50%),\n\t\tcolor-mix(in srgb, var(--ripple-color) 20%, transparent),\n\t\ttransparent 70%\n\t);\n}\n\n.ripple-button .ripple-hover::after {\n\tcontent: \"\";\n\tposition: absolute;\n\tleft: var(--ripple-x, 50%);\n\ttop: var(--ripple-y, 50%);\n\twidth: 72px;\n\theight: 72px;\n\tmargin: -36px 0 0 -36px;\n\tborder-radius: 50%;\n\tborder: 1px solid color-mix(in srgb, var(--ripple-color) 55%, transparent);\n\topacity: 0;\n\ttransform: scale(0.2);\n}\n\n.ripple-button:hover .ripple-hover {\n\topacity: 1;\n}\n\n.ripple-button:hover .ripple-hover::after {\n\tanimation: ripple-idle 1.8s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;\n}\n\n.ripple-button:hover {\n\tborder-color: color-mix(in srgb, var(--ripple-color) 35%, transparent);\n}\n\n@keyframes ripple-idle {\n\t0% {\n\t\ttransform: scale(0.2);\n\t\topacity: 0.9;\n\t}\n\t100% {\n\t\ttransform: scale(1);\n\t\topacity: 0;\n\t}\n}\n\n/* The border takes the ripple's tint while a ripple runs. */\n.ripple-button[data-rippling] {\n\tborder-color: color-mix(in srgb, var(--ripple-color) 55%, transparent);\n}\n\n/* A soft glow that blooms and fades… */\n.ripple-button .ripple-animation {\n\tbackground: radial-gradient(\n\t\tcircle,\n\t\tcolor-mix(in srgb, var(--ripple-color) 40%, transparent) 0%,\n\t\tcolor-mix(in srgb, var(--ripple-color) 16%, transparent) 45%,\n\t\ttransparent 70%\n\t);\n\tanimation: ripple-bloom var(--ripple-duration, 900ms) cubic-bezier(0.22, 0.61, 0.36, 1) forwards;\n}\n\n/* …and two fine rings, the second a beat behind, like a wave on water. */\n.ripple-button .ripple-animation::before,\n.ripple-button .ripple-animation::after {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: 50%;\n\tborder: 2px solid color-mix(in srgb, var(--ripple-color) 90%, transparent);\n\ttransform: scale(0);\n\topacity: 0;\n\tanimation: ripple-ring var(--ripple-duration, 900ms) cubic-bezier(0.22, 0.61, 0.36, 1) forwards;\n}\n\n.ripple-button .ripple-animation::after {\n\tborder-width: 1.5px;\n\tanimation-delay: calc(var(--ripple-duration, 900ms) * 0.12);\n}\n\n@keyframes ripple-bloom {\n\t0% {\n\t\ttransform: scale(0);\n\t\topacity: 1;\n\t}\n\t100% {\n\t\ttransform: scale(1);\n\t\topacity: 0;\n\t}\n}\n\n@keyframes ripple-ring {\n\t0% {\n\t\ttransform: scale(0);\n\t\topacity: 1;\n\t}\n\t60% {\n\t\topacity: 0.8;\n\t}\n\t100% {\n\t\ttransform: scale(1);\n\t\topacity: 0;\n\t}\n}\n\n/* Reduced motion: no expansion, just a brief glow where you pressed. */\n@media (prefers-reduced-motion: reduce) {\n\t.ripple-button:hover .ripple-hover::after {\n\t\tanimation: none;\n\t}\n\n\t.ripple-button .ripple-animation {\n\t\tanimation-name: ripple-fade;\n\t}\n\n\t.ripple-button .ripple-animation::before,\n\t.ripple-button .ripple-animation::after {\n\t\tdisplay: none;\n\t}\n\n\t@keyframes ripple-fade {\n\t\t0% {\n\t\t\ttransform: scale(1);\n\t\t\topacity: 1;\n\t\t}\n\t\t100% {\n\t\t\ttransform: scale(1);\n\t\t\topacity: 0;\n\t\t}\n\t}\n}\n\n/* The fade is motion: it only runs when the visitor has not asked for less. */\n@media (prefers-reduced-motion: no-preference) {\n\t.ripple-button .ripple-hover {\n\t\ttransition: opacity 0.3s ease;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/ripple-button/ripple-button.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/ripple-button?framework=react"}}