Ripple Button
A ripple spreads out from exactly where you click.
components/ui/ripple-button.tsx
"use client";
import { motion } from "motion/react";
import { useState, type ButtonHTMLAttributes, type MouseEvent } from "react";
type Ripple = { id: number; x: number; y: number; size: number };
type RippleButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
rippleColor?: string;
};
export function RippleButton({ children, className = "", rippleColor = "rgba(255,255,255,0.45)", onClick, ...props }: RippleButtonProps) {
const [ripples, setRipples] = useState<Ripple[]>([]);
function handleClick(e: MouseEvent<HTMLButtonElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2.2;
const ripple = { id: Date.now() + Math.random(), x: e.clientX - rect.left - size / 2, y: e.clientY - rect.top - size / 2, size };
setRipples((r) => [...r, ripple]);
onClick?.(e);
}
return (
<button
onClick={handleClick}
className={`relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-colors hover:bg-blue-700 ${className}`}
{...props}
>
{ripples.map((r) => (
<motion.span
key={r.id}
aria-hidden
className="pointer-events-none absolute rounded-full"
style={{ left: r.x, top: r.y, width: r.size, height: r.size, background: rippleColor }}
initial={{ scale: 0, opacity: 0.7 }}
animate={{ scale: 1, opacity: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
onAnimationComplete={() => setRipples((all) => all.filter((x) => x.id !== r.id))}
/>
))}
<span className="relative">{children}</span>
</button>
);
}
export default function Demo() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<RippleButton>Click anywhere on me</RippleButton>
<RippleButton rippleColor="rgba(37,99,235,0.18)" className="!bg-white !text-zinc-900 !shadow-sm ring-1 ring-zinc-200 hover:!bg-zinc-50">
Secondary ripple
</RippleButton>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/ripple-button.tsxcomponents/ui/ripple-button.tsx"use client"; import { motion } from "motion/react"; import { useState, type ButtonHTMLAttributes, type MouseEvent } from "react"; type Ripple = { id: number; x: number; y: number; size: number }; type RippleButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { rippleColor?: string; }; export function RippleButton({ children, className = "", rippleColor = "rgba(255,255,255,0.45)", onClick, ...props }: RippleButtonProps) { const [ripples, setRipples] = useState<Ripple[]>([]); function handleClick(e: MouseEvent<HTMLButtonElement>) { const rect = e.currentTarget.getBoundingClientRect(); const size = Math.max(rect.width, rect.height) * 2.2; const ripple = { id: Date.now() + Math.random(), x: e.clientX - rect.left - size / 2, y: e.clientY - rect.top - size / 2, size }; setRipples((r) => [...r, ripple]); onClick?.(e); } return ( <button onClick={handleClick} className={`relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-colors hover:bg-blue-700 ${className}`} {...props} > {ripples.map((r) => ( <motion.span key={r.id} aria-hidden className="pointer-events-none absolute rounded-full" style={{ left: r.x, top: r.y, width: r.size, height: r.size, background: rippleColor }} initial={{ scale: 0, opacity: 0.7 }} animate={{ scale: 1, opacity: 0 }} transition={{ duration: 0.75, ease: "easeOut" }} onAnimationComplete={() => setRipples((all) => all.filter((x) => x.id !== r.id))} /> ))} <span className="relative">{children}</span> </button> ); } export default function Demo() { return ( <div className="flex flex-wrap items-center justify-center gap-4"> <RippleButton>Click anywhere on me</RippleButton> <RippleButton rippleColor="rgba(37,99,235,0.18)" className="!bg-white !text-zinc-900 !shadow-sm ring-1 ring-zinc-200 hover:!bg-zinc-50"> Secondary ripple </RippleButton> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.