Ripple button
A click sends a soft circle out from the exact point you pressed, filling the button and fading away.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useRef, useState } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
type Ripple = { id: number; x: number; y: number; size: number };
function RippleButton({ label, ghost = false }: { label: string; ghost?: boolean }) {
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const reduced = usePrefersReducedMotion();
const spawn = (el: HTMLElement, x: number, y: number) => {
if (reduced) return;
const { width, height } = el.getBoundingClientRect();
const size = 2 * Math.hypot(Math.max(x, width - x), Math.max(y, height - y));
const id = nextId.current++;
setRipples((list) => [...list, { id, x, y, size }]);
};
const down = (event: React.PointerEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
spawn(event.currentTarget, event.clientX - rect.left, event.clientY - rect.top);
};
// Keyboard activation has no pointer position, so ripple from the centre.
const click = (event: React.MouseEvent<HTMLButtonElement>) => {
if (event.detail !== 0) return;
const rect = event.currentTarget.getBoundingClientRect();
spawn(event.currentTarget, rect.width / 2, rect.height / 2);
};
return (
<button type="button" className={`${styles.button} ${ghost ? styles.ghost : ""}`} onPointerDown={down} onClick={click}>
{ripples.map((r) => (
<span
key={r.id}
className={styles.ripple}
style={{ left: r.x, top: r.y, width: r.size, height: r.size }}
onAnimationEnd={() => setRipples((list) => list.filter((item) => item.id !== r.id))}
aria-hidden="true"
/>
))}
<span className={styles.label}>{label}</span>
</button>
);
}
export default function Demo() {
return (
<div className={styles.root}>
<div className={styles.row}>
<RippleButton label="Add to cart" />
<RippleButton label="Save for later" ghost />
</div>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.row { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; }
.button {
position: relative; overflow: hidden; isolation: isolate;
min-height: 52px; padding: 0 30px; border-radius: 14px; border: 1.5px solid var(--ink);
background: var(--ink); color: var(--white); font-size: 15px; font-weight: 600; cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: background-color .18s, border-color .18s;
}
.ghost { background: var(--white); color: var(--ink); border-color: var(--line); }
.label { position: relative; z-index: 1; }
.ripple {
position: absolute; border-radius: 50%; pointer-events: none; translate: -50% -50%;
background: color-mix(in srgb, var(--white) 32%, transparent);
animation: ripple .7s var(--out) forwards;
}
.ghost .ripple { background: color-mix(in srgb, var(--accent) 35%, transparent); }
@keyframes ripple { from { scale: 0; opacity: 1; } 60% { opacity: .8; } to { scale: 1; opacity: 0; } }
@media (hover: hover) {
.button:hover { background: var(--ink-hover); border-color: var(--ink-hover); }
.ghost:hover { background: var(--white); border-color: var(--ink); }
}
@media (prefers-reduced-motion: reduce) {
.button:active { background: var(--ink-hover); }
.ghost:active { background: var(--accent-pale); }
}
Use it for
- Add to cart and other repeatable actions where each press should register
- Touch-first interfaces, where the ripple confirms the tap under the finger
Avoid
- Links that navigate away immediately, since the ripple is never seen
- Very small icon buttons, where the circle has no room to travel
How it works
On pointerdown the click point is measured relative to the button and a span is added at that spot. Its diameter is twice the distance to the farthest corner, so it always covers the button. A keyframe scales it from 0 to 1 while fading out, and animationend removes it from state. Keyboard clicks (event.detail of 0) ripple from the centre.
Accessibility
The ripple spans are aria-hidden and never block input. Reduced motion skips the ripple and shows a pressed colour on :active instead. Enter and Space work as on any native button.