Material-style click ripples with a tasteful spring, as a reusable wrapper plus a useRipples hook.
npx shadcn@latest add @paragon/ripple-click"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/**
* RippleClick — material-style click ripples with a tasteful spring. Ships both
* a reusable wrapper (`<RippleClick>`) and a hook (`useRipples`) so you can wire
* ripples onto your own element.
*
* The ripple layer is `pointer-events-none` and `aria-hidden`, so it never
* interferes with the wrapped control's semantics or focus. Ripples originate
* at the exact pointer point (keyboard activation ripples from center). Under
* reduced motion ripples are suppressed. Deterministic keys — no Date.now in
* render.
*/
interface Ripple {
key: number;
x: number;
y: number;
size: number;
}
export interface UseRipplesOptions {
color?: string;
duration?: number;
disabled?: boolean;
}
export function useRipples({
color = "currentColor",
duration = 0.6,
disabled = false,
}: UseRipplesOptions = {}) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const seq = React.useRef(0);
const reduce = useReducedMotion();
const spawn = React.useCallback(
(e: React.PointerEvent<HTMLElement>) => {
if (disabled || reduce) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// cover the farthest corner
const size =
2 *
Math.max(
Math.hypot(x, y),
Math.hypot(rect.width - x, y),
Math.hypot(x, rect.height - y),
Math.hypot(rect.width - x, rect.height - y),
);
const key = seq.current++;
setRipples((r) => [...r, { key, x, y, size }]);
},
[disabled, reduce],
);
const remove = React.useCallback((key: number) => {
setRipples((r) => r.filter((rp) => rp.key !== key));
}, []);
const rippleLayer = (
<span
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"
>
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.key}
className="absolute rounded-full"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
marginLeft: -r.size / 2,
marginTop: -r.size / 2,
background: color,
}}
initial={{ scale: 0, opacity: 0.35 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{
scale: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
opacity: { duration, ease: [0.22, 1, 0.36, 1] },
}}
onAnimationComplete={() => remove(r.key)}
/>
))}
</AnimatePresence>
</span>
);
return { spawn, rippleLayer, ripples } as const;
}
export interface RippleClickProps extends React.ComponentProps<"span"> {
/** Ripple color (defaults to currentColor at low alpha). */
color?: string;
/** Fade duration in seconds. */
duration?: number;
/** Suppress ripples. */
disabled?: boolean;
}
/**
* Drop-in wrapper: renders an inline-flex `<span>` that emits a ripple on
* pointer-down. Put a real `<button>`/`<a>` inside for semantics.
*/
export function RippleClick({
color = "currentColor",
duration = 0.6,
disabled = false,
className,
children,
onPointerDown,
...props
}: RippleClickProps) {
const { spawn, rippleLayer } = useRipples({ color, duration, disabled });
return (
<span
className={cn(
"relative inline-flex overflow-hidden rounded-[inherit] isolate",
className,
)}
onPointerDown={(e) => {
spawn(e);
onPointerDown?.(e);
}}
{...props}
>
{children}
{rippleLayer}
</span>
);
}