A set of six buttons that answer every press with a ring of ink spreading from the point of contact, in the style of material design. There is a filled pill, an outlined pill, a dark button with an icon, a save toggle with a count, a round icon button and a wide pay button inside an order summary card. Until someone interacts, the demo plays a ripple on each button in turn. On phones the buttons wrap and the card moves below.
// Adapted from Magic UI (https://magicui.design/docs/components/ripple-button)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { ButtonHTMLAttributes, CSSProperties, MouseEvent, ReactNode } from "react";
type Ink = { x: number; y: number; size: number; key: number };
/** Milliseconds one ripple takes to spread and fade. */
const DURATION = 1000;
interface RippleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
/** Colour of the ink. White suits filled buttons, the text colour suits outlined ones. */
rippleColor?: string;
/** Change this number to play a ripple from the centre without a click (used by the demo loop). */
pulse?: number;
}
function RippleButton({ children, rippleColor = "#ffffff", pulse = 0, className = "", onClick, ...props }: RippleButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const [inks, setInks] = useState<Ink[]>([]);
const timers = useRef<number[]>([]);
const spread = (px: number | null, py: number | null) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
// Keyboard and scripted clicks have no position, so those start from the middle.
const cx = px === null ? rect.width / 2 : px - rect.left;
const cy = py === null ? rect.height / 2 : py - rect.top;
const key = performance.now() + Math.random();
setInks((list) => [...list, { x: cx - size / 2, y: cy - size / 2, size, key }]);
timers.current.push(window.setTimeout(() => setInks((list) => list.filter((k) => k.key !== key)), DURATION));
};
useEffect(() => {
if (pulse > 0) spread(null, null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pulse]);
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
const fromKeyboard = e.detail === 0;
spread(fromKeyboard ? null : e.clientX, fromKeyboard ? null : e.clientY);
onClick?.(e);
};
return (
<button
ref={ref}
type="button"
onClick={handleClick}
className={`relative inline-flex select-none items-center justify-center gap-2 overflow-hidden transition-transform duration-150 active:scale-[0.97] focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${className}`}
{...props}
>
<span className="relative z-10 inline-flex items-center gap-2">{children}</span>
<span aria-hidden="true" className="pointer-events-none absolute inset-0">
{inks.map((k) => (
<span
key={k.key}
className="click-ripple-buttons-with-ink-spread-ink absolute rounded-full"
style={{ width: k.size, height: k.size, left: k.x, top: k.y, backgroundColor: rippleColor, "--ink-duration": `${DURATION}ms` } as CSSProperties}
/>
))}
</span>
</button>
);
}
const order = { item: "Weekend tote in moss canvas", qty: 1, subtotal: "$118.00", shipping: "$6.00", total: "$124.00" };
export default function ClickRippleButtonsWithInkSpread() {
const [beat, setBeat] = useState(0);
const [touched, setTouched] = useState(false);
const [saved, setSaved] = useState(false);
const [likes, setLikes] = useState(212);
// Demo loop: plays a ripple on each button in turn until someone clicks.
useEffect(() => {
if (touched || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = window.setInterval(() => setBeat((b) => b + 1), 600);
return () => window.clearInterval(id);
}, [touched]);
const COUNT = 6;
const pulseFor = (i: number) => (touched ? 0 : beat % COUNT === i ? beat + 1 : 0);
const stop = () => setTouched(true);
return (
<section className="bg-slate-50 px-4 py-16 sm:px-6 sm:py-20" onPointerDown={stop} onKeyDown={stop}>
<style>{`
@keyframes click-ripple-buttons-with-ink-spread-grow {
from { transform: scale(0); opacity: 0.55; }
to { transform: scale(2.2); opacity: 0; }
}
.click-ripple-buttons-with-ink-spread-ink {
transform: scale(0);
animation: click-ripple-buttons-with-ink-spread-grow var(--ink-duration) cubic-bezier(0.3, 0.3, 0.5, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.click-ripple-buttons-with-ink-spread-ink { animation: none; transform: scale(2.2); opacity: 0.14; }
}
`}</style>
<div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2">
<div>
<p className="text-sm font-semibold uppercase tracking-widest text-indigo-600">Ripple buttons</p>
<h2 className="mt-3 text-4xl font-semibold tracking-tight text-slate-900">Every press answers back</h2>
<p className="mt-4 max-w-md text-lg leading-relaxed text-slate-600">Ink spreads from the exact point you click or tap, on any shape of button. Try them: the ripple starts under your finger.</p>
<div className="mt-9 flex flex-wrap items-center gap-3">
<RippleButton pulse={pulseFor(0)} className="h-12 rounded-full bg-indigo-600 px-7 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 hover:bg-indigo-500 focus-visible:ring-indigo-600">
Start free trial
</RippleButton>
<RippleButton pulse={pulseFor(1)} rippleColor="#4f46e5" className="h-12 rounded-full border-2 border-indigo-200 bg-white px-7 text-sm font-semibold text-indigo-700 hover:border-indigo-400 focus-visible:ring-indigo-600">
Watch the tour
</RippleButton>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<RippleButton pulse={pulseFor(2)} rippleColor="#fbbf24" className="h-12 rounded-lg bg-slate-900 px-6 text-sm font-semibold text-white hover:bg-slate-800 focus-visible:ring-slate-900">
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10 3v10m0 0l-4-4m4 4l4-4M4 17h12" />
</svg>
Download invoice
</RippleButton>
<RippleButton
pulse={pulseFor(3)}
rippleColor="#e11d48"
aria-pressed={saved}
onClick={() => { setSaved((s) => !s); setLikes((n) => n + (saved ? -1 : 1)); }}
className={`h-12 rounded-lg border px-5 text-sm font-semibold focus-visible:ring-rose-600 ${saved ? "border-rose-200 bg-rose-50 text-rose-700" : "border-slate-300 bg-white text-slate-700 hover:border-slate-400"}`}
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill={saved ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true">
<path d="M10 17s-6.500-3.900-6.500-8.300A3.600 3.600 0 0110 6.500a3.600 3.600 0 016.500 2.200C16.500 13.100 10 17 10 17z" />
</svg>
{saved ? "Saved" : "Save"} <span className="font-mono text-xs opacity-70">{likes}</span>
</RippleButton>
<RippleButton pulse={pulseFor(4)} rippleColor="#0f172a" aria-label="More options" className="h-12 w-12 rounded-full bg-slate-200 text-slate-700 hover:bg-slate-300 focus-visible:ring-slate-900">
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<circle cx="4" cy="10" r="1.6" />
<circle cx="10" cy="10" r="1.6" />
<circle cx="16" cy="10" r="1.6" />
</svg>
</RippleButton>
</div>
</div>
<div className="rounded-3xl bg-white p-6 shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 sm:p-8">
<h3 className="text-lg font-semibold text-slate-900">Order summary</h3>
<div className="mt-5 flex items-center gap-4">
<div className="h-16 w-16 flex-none rounded-2xl bg-gradient-to-br from-lime-200 to-emerald-400" aria-hidden="true" />
<div className="min-w-0">
<p className="font-medium text-slate-900">{order.item}</p>
<p className="text-sm text-slate-600">Quantity {order.qty}</p>
</div>
</div>
<dl className="mt-6 space-y-2.5 border-t border-slate-200 pt-5 text-sm">
<div className="flex justify-between text-slate-600"><dt>Subtotal</dt><dd>{order.subtotal}</dd></div>
<div className="flex justify-between text-slate-600"><dt>Shipping</dt><dd>{order.shipping}</dd></div>
<div className="flex justify-between pt-2 text-base font-semibold text-slate-900"><dt>Total</dt><dd>{order.total}</dd></div>
</dl>
<RippleButton pulse={pulseFor(5)} className="mt-7 h-14 w-full rounded-2xl bg-emerald-600 text-base font-semibold text-white shadow-lg shadow-emerald-600/25 hover:bg-emerald-500 focus-visible:ring-emerald-600">
Pay {order.total}
</RippleButton>
<p className="mt-3 text-center text-xs text-slate-600">Secure checkout. You can cancel within 30 minutes.</p>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/click-ripple-buttons-with-ink-spread.json
The click handler reads the pointer position, subtracts the button's position on the page and places a circle centred on that point. The circle starts at scale zero and a keyframe animation grows and fades it. The button has `overflow-hidden`, so the ink is clipped to the button shape.
Each click adds its own circle to a small list, and each one is removed by a timer when its animation ends. Ripples overlap naturally and none is cut short.
A click fired by Enter or Space reports a detail value of zero and no real coordinates. The component checks for that and starts the ink from the centre.
Yes. Copy the ink layer and the `spread` function into any element that is `relative` and `overflow-hidden`. Keep the content in a `relative z-10` wrapper so the ink runs underneath the label.
No. Your `onClick` runs in the same event as the ripple starts. The animation is visual only.
Adapted from Ripple Button in Magic UI, used under its open-source licence. Licences and credits
Related: Shimmer Button with Light Sweep in Three Sizes, Magnetic Button That Follows the Pointer, Hold to Confirm Button with Progress Ring.
More animated buttons or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.