Add Ripple effect to everything for a smooth, cursor-tracking fill animation.
Hover to reveal the ripple filling the container from cursor position.
<button id="myButton" className="px-6 py-2 group z-10 font-medium border-2 border-accent-demo cursor-pointer rounded-lg relative overflow-hidden">
<Ripple parent="#myButton" className="bg-accent-demo" />
<span className="relative z-10">I'm a button</span>
</button>
<div
ref={cardRef}
className="group relative w-64 rounded-2xl border border-[#2fc9da]/20 bg-[#2fc9da]/3 px-6 py-5 overflow-hidden cursor-pointer transition-shadow duration-500 hover:shadow-[0_0_24px_-4px_rgba(47,201,218,0.25)]"
>
<div className="absolute top-0 left-6 h-px w-12 bg-linear-to-r from-transparent via-[#2fc9da] to-transparent" />
<div className="relative z-10 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-widest text-[#2fc9da] group-hover:text-white transition-colors duration-300">
Interactive
</span>
<span className="size-1.5 rounded-full bg-[#2fc9da] animate-pulse" />
</div>
<div>
<h3 className="font-serif text-lg leading-tight group-hover:text-white transition-colors duration-300">
I'm a card
</h3>
<p className="mt-1 text-sm text-muted-foreground mb-0 group-hover:text-white transition-colors duration-300">
Hover to reveal the ripple filling the container from cursor position.
</p>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground group-hover:text-white transition-colors duration-300">
<span>Try it</span>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="translate-x-0 group-hover:translate-x-1 transition-transform duration-300"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</div>
</div>
<Ripple parent={cardRef} className="bg-[#2fc9da]/80 size-52" />
</div>
<Button ref={buttonRef} className="relative overflow-hidden group" >
<div className="z-20 relative">
Design System Button
</div>
<Ripple parent={buttonRef} className="bg-primary" />
</Button>npm install motionbun add motiondeno add motionpnpm add motionyarn add motionCopy and paste the following code into your project.
"use client";
import {
AnimatePresence,
motion,
type SpringOptions,
useSpring,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const easePower4Out = (t: number) => 1 - (1 - t) ** 4;
const easeExpoOut = (t: number) => (t === 1 ? 1 : 1 - 2 ** (-10 * t));
interface RippleProps {
className?: string;
/** React ref or CSS selector string pointing to the parent element. */
parent: React.RefObject<HTMLElement | null> | string;
/**
* Custom Spring configuration for the ripple animation.
* @default { duration: 0.2, bounce: 0 }
*/
springConfig?: SpringOptions;
}
const defaultSpringConfig: SpringOptions = { bounce: 0, duration: 0.2 };
const exitOvershoot = 0.2;
const isRef = (
value: RippleProps["parent"]
): value is React.RefObject<HTMLElement | null> => typeof value !== "string";
const getExitTarget = ({
cursorPosition,
minEdge,
maxEdge,
axisSize,
axisOffset,
currentPosition,
}: {
cursorPosition: number;
minEdge: number;
maxEdge: number;
axisSize: number;
axisOffset: number;
currentPosition: number;
}) => {
const extra = axisOffset * exitOvershoot;
if (cursorPosition < minEdge) {
return -axisOffset - extra;
}
if (cursorPosition > maxEdge) {
return axisSize - axisOffset + extra;
}
return currentPosition;
};
function Ripple({
parent,
className,
springConfig = defaultSpringConfig,
}: RippleProps) {
const rippleRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const x = useSpring(0, springConfig);
const y = useSpring(0, springConfig);
useEffect(() => {
const element = isRef(parent)
? parent.current
: document.querySelector<HTMLElement>(parent);
if (!element) {
return;
}
const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = (e: MouseEvent) => {
const rect = element.getBoundingClientRect();
if (!rippleRef.current) {
return;
}
const offsetX = rippleRef.current.offsetWidth / 2;
const offsetY = rippleRef.current.offsetHeight / 2;
const xNew = e.clientX - rect.left - offsetX;
const yNew = e.clientY - rect.top - offsetY;
x.set(
getExitTarget({
axisOffset: offsetX,
axisSize: rect.width,
currentPosition: xNew,
cursorPosition: e.clientX,
maxEdge: rect.right,
minEdge: rect.left,
})
);
y.set(
getExitTarget({
axisOffset: offsetY,
axisSize: rect.height,
currentPosition: yNew,
cursorPosition: e.clientY,
maxEdge: rect.bottom,
minEdge: rect.top,
})
);
const unsub = x.on("animationComplete", () => {
unsub();
setIsHovered(false);
});
};
const handleMouseMove = (event: MouseEvent) => {
const rect = element.getBoundingClientRect();
if (!rippleRef.current) {
return;
}
const offsetX = rippleRef.current.offsetWidth / 2;
const offsetY = rippleRef.current.offsetHeight / 2;
x.set(event.clientX - rect.left - offsetX);
y.set(event.clientY - rect.top - offsetY);
};
element.addEventListener("mouseenter", handleMouseEnter);
element.addEventListener("mouseleave", handleMouseLeave);
element.addEventListener("mousemove", handleMouseMove);
return () => {
element.removeEventListener("mouseenter", handleMouseEnter);
element.removeEventListener("mouseleave", handleMouseLeave);
element.removeEventListener("mousemove", handleMouseMove);
};
}, [parent, y.set, x.set, x.on]);
return (
<AnimatePresence>
{isHovered ? (
<motion.div
animate={{
scale: 1,
transition: { delay: 0.01, duration: 0.4, ease: easePower4Out },
}}
aria-hidden="false"
className={cn(
"pointer-events-none absolute top-0 left-0 z-1 size-42 rounded-full",
className
)}
exit={{ scale: 0, transition: { duration: 0.4, ease: easeExpoOut } }}
initial={{ scale: 0 }}
key="ripple"
ref={rippleRef}
style={{ x, y }}
/>
) : null}
</AnimatePresence>
);
}
export { Ripple };relative and overflow-hidden. Also, the content shown on top of the ripple must have a z-index value bigger than 1, otherwise the ripple will go over the content.import { Ripple } from "@/components/ripple";It search using querySelector under the hood, so I recommend giving the parent element an id and pass the CSS selector as a string.
<button id="myButton" className="relative overflow-hidden">
<Ripple parent="#myButton" className="bg-white" />
<span className="z-10">Click me</span>
</button>For a more React-idiomatic approach, pass a ref to the parent element.
const buttonRef = useRef<HTMLButtonElement>(null);
<button ref={buttonRef} className="relative overflow-hidden">
<Ripple parent={buttonRef} className="bg-white" />
<span className="z-10">Click me</span>
</button>You can customize the animation speed and easing through the springConfig prop. Since the ripple is a mouse-tracking animation, I recommend to use a spring configuration with a shorter duration and no bounce.
<Ripple parent={ref} className="bg-white" springConfig={{ damping: 30, stiffness: 200, mass: 0.2 }} />Control the ripple style through the className prop.
<Ripple parent={ref} className="bg-white size-10 sm:size-20 md:size-32 lg:size-46" />You can drop Ripple inside any design system button (e.g., Shadcn UI) — just ensure the button has relative and overflow-hidden.
import { Button } from "@/components/ui/button";
import { Ripple } from "@/components/ripple";
const buttonRef = useRef<HTMLButtonElement>(null);
<Button ref={buttonRef} className="relative overflow-hidden group">
<div className="z-20">
Design System Button
</div>
<Ripple parent={buttonRef} className="bg-primary" />
</Button>| Prop | Type | Default Value | Description |
|---|---|---|---|
| parent | React.RefObject<HTMLElement | null> | string | - | React ref or CSS selector pointing to the parent element |
| className? | string | - | Class name applied to the ripple div |
| springConfig? | SpringOptions | { duration: 0.2, bounce: 0 } | Custom Spring configuration for the ripple animation |