Ripple Click Effect
A Material-style ripple that expands from the exact click point.
- Category
- Components
- Added
- 2026-08-22
- Deps
- 1
- Updated
- 2026-09-01
Props
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
let rippleId = 0;
export function RippleButton({
label = "Click me",
color = "light",
}: {
label?: string;
color?: "light" | "dark";
}) {
const [ripples, setRipples] = useState<{ id: number; x: number; y: number; size: number }[]>([]);
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const size = Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y)) * 2;
const id = rippleId++;
setRipples((prev) => [...prev, { id, x, y, size }]);
setTimeout(() => setRipples((prev) => prev.filter((r) => r.id !== id)), 700);
}
return (
<button
onClick={handleClick}
className="relative overflow-hidden rounded-xl bg-red-500 px-8 py-4 font-semibold text-white"
>
{label}
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="pointer-events-none 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 === "light" ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.35)",
}}
initial={{ scale: 0, opacity: 0.6 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
))}
</AnimatePresence>
</button>
);
}
About this component
A ripple that always starts from a button's center looks fake the moment someone clicks near a corner; a real Material ripple originates exactly where the pointer landed. This computes the click point relative to the button via getBoundingClientRect, then sizes the ripple as twice the distance from that point to the farthest corner, which is what guarantees full coverage regardless of where the click happened. Sizing off the button's average dimensions instead would leave a visible sliver uncovered on an off-center click. Each ripple is tracked as its own object in an array with a unique id, so rapid repeated clicks spawn independent overlapping ripples rather than one ripple restarting and truncating the previous click's animation, a common failure mode when a single ripple element gets reused. AnimatePresence handles the exit animation once a matching setTimeout prunes the ripple from state. The button needs both overflow-hidden and relative positioning for the ripple to clip to its rounded corners instead of spilling past them, which is easy to lose if you copy just the ripple logic into an existing button.
Curator’s note
Sizing the ripple off the click point's distance to the farthest corner (not the button's average size) is what stops a corner-click from leaving an uncovered sliver — easy to miss until you actually click near an edge.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Confetti Burst Button
componentsA button that scatters a quick burst of confetti particles on click.
Magnetic Button
componentsA button whose label leans toward the cursor with a springy pull.
Share Button Expand
componentsA share icon that expands into a row of social options with a copy-link state.