Ripple Button
Original · freeButtons with a material-style ripple from the click point.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/btn-ripple.json"use client";
import {
useCallback,
useRef,
useState,
type ReactNode,
type PointerEvent as ReactPointerEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
type Ripple = { id: number; x: number; y: number; size: number };
type Variant = "solid" | "tonal" | "outline" | "ghost";
type Size = "sm" | "md" | "lg";
const variantClasses: Record<Variant, string> = {
solid:
"bg-indigo-600 text-white hover:bg-indigo-500 active:bg-indigo-700 shadow-lg shadow-indigo-600/25 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:active:bg-indigo-600 dark:shadow-indigo-500/20",
tonal:
"bg-indigo-100 text-indigo-800 hover:bg-indigo-200 active:bg-indigo-300 dark:bg-indigo-500/15 dark:text-indigo-200 dark:hover:bg-indigo-500/25 dark:active:bg-indigo-500/35",
outline:
"border border-slate-300 text-slate-800 hover:bg-slate-100 active:bg-slate-200 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800 dark:active:bg-slate-700/70",
ghost:
"text-slate-700 hover:bg-slate-100 active:bg-slate-200 dark:text-slate-200 dark:hover:bg-slate-800 dark:active:bg-slate-700/70",
};
const rippleTint: Record<Variant, string> = {
solid: "bg-white/40",
tonal: "bg-indigo-500/25",
outline: "bg-slate-500/20 dark:bg-slate-300/20",
ghost: "bg-slate-500/20 dark:bg-slate-300/20",
};
const sizeClasses: Record<Size, string> = {
sm: "h-9 px-3.5 text-sm gap-1.5 rounded-lg",
md: "h-11 px-5 text-sm gap-2 rounded-xl",
lg: "h-14 px-7 text-base gap-2.5 rounded-2xl",
};
const iconSizeClasses: Record<Size, string> = {
sm: "h-9 w-9 rounded-lg",
md: "h-11 w-11 rounded-xl",
lg: "h-14 w-14 rounded-2xl",
};
type RippleButtonProps = {
children?: ReactNode;
variant?: Variant;
size?: Size;
disabled?: boolean;
loading?: boolean;
iconOnly?: boolean;
ariaLabel?: string;
pressed?: boolean;
onActivate?: () => void;
className?: string;
};
function useRipples() {
const [ripples, setRipples] = useState<Ripple[]>([]);
const idRef = useRef(0);
const spawn = useCallback(
(el: HTMLElement, clientX: number, clientY: number) => {
const rect = el.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const size =
Math.max(
Math.hypot(x, y),
Math.hypot(rect.width - x, y),
Math.hypot(x, rect.height - y),
Math.hypot(rect.width - x, rect.height - y),
) * 2;
const id = idRef.current++;
setRipples((prev) => [...prev, { id, x, y, size }]);
window.setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, 650);
},
[],
);
return { ripples, spawn };
}
function RippleButton({
children,
variant = "solid",
size = "md",
disabled = false,
loading = false,
iconOnly = false,
ariaLabel,
pressed,
onActivate,
className = "",
}: RippleButtonProps) {
const { ripples, spawn } = useRipples();
const isToggle = typeof pressed === "boolean";
const emit = useCallback(
(e: ReactPointerEvent<HTMLButtonElement> | ReactKeyboardEvent<HTMLButtonElement>) => {
if (disabled || loading) return;
const el = e.currentTarget;
if ("clientX" in e && e.clientX !== 0) {
spawn(el, e.clientX, e.clientY);
} else {
const rect = el.getBoundingClientRect();
spawn(el, rect.left + rect.width / 2, rect.top + rect.height / 2);
}
},
[disabled, loading, spawn],
);
return (
<button
type="button"
aria-label={ariaLabel}
aria-pressed={isToggle ? pressed : undefined}
aria-busy={loading || undefined}
disabled={disabled}
onPointerDown={emit}
onKeyDown={(e) => {
if (e.key === " " || e.key === "Enter") emit(e);
}}
onClick={() => {
if (!disabled && !loading) onActivate?.();
}}
className={[
"brip-btn relative isolate inline-flex select-none items-center justify-center overflow-hidden font-semibold tracking-tight",
"transition-[transform,background-color,box-shadow,color] duration-150 ease-out active:scale-[0.97]",
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 disabled:shadow-none",
iconOnly ? iconSizeClasses[size] : sizeClasses[size],
variantClasses[variant],
isToggle && pressed ? "ring-2 ring-inset ring-indigo-400/60 dark:ring-indigo-300/50" : "",
className,
].join(" ")}
>
{ripples.map((r) => (
<span
key={r.id}
aria-hidden="true"
className={`brip-ink pointer-events-none absolute z-0 rounded-full ${rippleTint[variant]}`}
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
marginLeft: -r.size / 2,
marginTop: -r.size / 2,
}}
/>
))}
<span className="relative z-10 inline-flex items-center justify-center gap-[inherit]">
{loading ? (
<>
<svg
className="brip-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
{!iconOnly && <span>Working…</span>}
</>
) : (
children
)}
</span>
</button>
);
}
function HeartIcon({ filled }: { filled: boolean }) {
return (
<svg
className={`h-5 w-5 transition-transform duration-200 ${filled ? "scale-110" : "scale-100"}`}
viewBox="0 0 24 24"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z" />
</svg>
);
}
function ArrowIcon() {
return (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
);
}
function BoltIcon() {
return (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2 4.5 13.5H11l-1 8.5L19.5 10H13l1-8Z" />
</svg>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-3 border-t border-slate-200/70 py-6 first:border-t-0 dark:border-slate-800/70 sm:flex-row sm:items-center">
<span className="w-28 shrink-0 font-mono text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500">
{label}
</span>
<div className="flex flex-wrap items-center gap-3">{children}</div>
</div>
);
}
export default function BtnRipple() {
const [liked, setLiked] = useState(false);
const [notify, setNotify] = useState(true);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = useCallback(() => {
setSubmitting(true);
window.setTimeout(() => setSubmitting(false), 1800);
}, []);
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-50 sm:px-10">
<style>{`
@keyframes brip-expand {
0% { transform: scale(0); opacity: 0.55; }
100% { transform: scale(1); opacity: 0; }
}
@keyframes brip-rotate {
to { transform: rotate(360deg); }
}
.brip-ink { transform: scale(0); animation: brip-expand 0.62s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; }
.brip-spin { animation: brip-rotate 0.75s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.brip-ink { animation-duration: 0.01ms; opacity: 0; }
.brip-spin { animation-duration: 1.4s; }
.brip-btn { transition-duration: 0.01ms; }
.brip-btn:active { transform: none; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20"
/>
<div className="relative mx-auto max-w-3xl">
<header className="mb-10">
<p className="mb-3 font-mono text-xs uppercase tracking-[0.25em] text-indigo-500 dark:text-indigo-400">
FreeCode / buttons
</p>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">Ripple buttons</h2>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-slate-400">
A material-style ink ripple that emanates from the exact point you press — pointer or
keyboard. Tactile, accessible, and self-contained.
</p>
</header>
<div className="rounded-3xl border border-slate-200 bg-white/70 px-6 py-2 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50 sm:px-8">
<Row label="Variants">
<RippleButton variant="solid" onActivate={() => undefined}>
Deploy build
</RippleButton>
<RippleButton variant="tonal" onActivate={() => undefined}>
Save draft
</RippleButton>
<RippleButton variant="outline" onActivate={() => undefined}>
Preview
</RippleButton>
<RippleButton variant="ghost" onActivate={() => undefined}>
Cancel
</RippleButton>
</Row>
<Row label="Sizes">
<RippleButton size="sm" variant="solid">
Small
</RippleButton>
<RippleButton size="md" variant="solid">
Medium
</RippleButton>
<RippleButton size="lg" variant="solid">
Large
</RippleButton>
</Row>
<Row label="With icons">
<RippleButton variant="solid">
<BoltIcon />
Upgrade to Pro
</RippleButton>
<RippleButton variant="tonal">
Continue
<ArrowIcon />
</RippleButton>
<RippleButton variant="outline" iconOnly size="md" ariaLabel="Add to favorites">
<HeartIcon filled={false} />
</RippleButton>
</Row>
<Row label="States">
<RippleButton
variant="solid"
loading={submitting}
onActivate={handleSubmit}
>
{submitting ? "Working…" : "Submit order"}
</RippleButton>
<RippleButton variant="solid" disabled>
Sold out
</RippleButton>
<RippleButton variant="outline" disabled>
Unavailable
</RippleButton>
</Row>
<Row label="Toggles">
<RippleButton
variant={liked ? "solid" : "outline"}
pressed={liked}
onActivate={() => setLiked((v) => !v)}
ariaLabel={liked ? "Remove from favorites" : "Add to favorites"}
iconOnly
>
<HeartIcon filled={liked} />
</RippleButton>
<RippleButton
variant={notify ? "tonal" : "ghost"}
pressed={notify}
onActivate={() => setNotify((v) => !v)}
>
{notify ? "Notifications on" : "Notifications off"}
</RippleButton>
</Row>
</div>
<p className="mt-6 text-center font-mono text-xs text-slate-400 dark:text-slate-600">
Press anywhere on a button — the ripple starts where you touch.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Shimmer Button
MITA dark pill button with a continuous conic-gradient light shimmer sweeping around its border.

Rainbow Gradient Button
MITA button wrapped in an animated multi-colour gradient border with a matching blurred underglow.

Interactive Hover Button
MITA pill button whose dot expands to fill the surface and reveals an arrow label on hover.

Pulsating Button
MITA solid button that emits a soft, continuously pulsing glow ring to draw the eye to the primary action.

Shiny Button
MITA frosted-glass button with a spring-animated light sweeping across its text and border.

Solid Set Button
OriginalA set of solid buttons: primary, secondary, success and danger, in three sizes.

Outline Set Button
OriginalOutline button variants across colours and sizes with a hover fill.

Ghost Set Button
OriginalGhost and text buttons with subtle hover backgrounds.

Soft Set Button
OriginalSoft, tinted buttons across semantic colours.

Gradient Set Button
OriginalGradient-filled buttons with a hover shift.
Icon Leading Button
OriginalButtons with leading and trailing icons.
Icon Only Button
OriginalSquare and circular icon-only buttons with tooltips and aria-labels.

