UI Components Easy
Ripple Button
A Material Design inspired button that shows a ripple wave effect expanding from the exact click point.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #f1f5f9;
min-height: 100vh;
}
.demo {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
padding: 2rem;
}
.demo-title {
font-size: 1.5rem;
font-weight: 700;
color: #e2e8f0;
}
.hint {
color: #525252;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2rem;
}
/* --- Ripple button base --- */
.ripple-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.875rem 2rem;
border-radius: 10px;
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
border: none;
outline: none;
color: #fff;
overflow: hidden;
transition: transform 0.15s ease, box-shadow 0.2s ease;
}
.ripple-btn:hover {
transform: translateY(-1px);
}
.ripple-btn:active {
transform: translateY(0);
}
/* Sizes */
.ripple-btn--sm {
padding: 0.625rem 1.5rem;
font-size: 0.8125rem;
}
.ripple-btn--lg {
padding: 1.125rem 2.75rem;
font-size: 1.0625rem;
}
/* Color variants */
.ripple-btn--blue {
background: #3b82f6;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.35);
}
.ripple-btn--blue:hover {
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.45);
}
.ripple-btn--purple {
background: #8b5cf6;
box-shadow: 0 4px 14px rgba(139, 92, 246, 0.35);
}
.ripple-btn--purple:hover {
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.45);
}
.ripple-btn--emerald {
background: #10b981;
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.35);
}
.ripple-btn--emerald:hover {
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.45);
}
.ripple-btn--rose {
background: #f43f5e;
box-shadow: 0 4px 14px rgba(244, 63, 94, 0.35);
}
.ripple-btn--ghost {
background: transparent;
color: #cbd5e1;
border: 1.5px solid #334155;
}
.ripple-btn--ghost:hover {
border-color: #64748b;
color: #f1f5f9;
}
.ripple-btn--amber {
background: #f59e0b;
color: #0a0a0a;
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.35);
}
/* Ripple span */
.ripple-btn__ripple {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
transform: scale(0);
animation: ripple-expand 0.6s ease-out forwards;
pointer-events: none;
}
.ripple-btn--ghost .ripple-btn__ripple {
background: rgba(148, 163, 184, 0.2);
}
@keyframes ripple-expand {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(4);
opacity: 0;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.ripple-btn__ripple {
animation: none;
display: none;
}
}// Ripple Button — click ripple effect from cursor position
(function () {
"use strict";
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const buttons = document.querySelectorAll("[data-ripple]");
buttons.forEach((btn) => {
btn.addEventListener("click", (e) => {
const rect = btn.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
const ripple = document.createElement("span");
ripple.className = "ripple-btn__ripple";
ripple.style.width = `${size}px`;
ripple.style.height = `${size}px`;
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
btn.appendChild(ripple);
ripple.addEventListener("animationend", () => {
ripple.remove();
});
});
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ripple Button</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h2 class="demo-title">Ripple Buttons</h2>
<p class="hint">Click the buttons to see the ripple effect</p>
<div class="button-row">
<button class="ripple-btn ripple-btn--blue" data-ripple>
Click Me
</button>
<button class="ripple-btn ripple-btn--purple" data-ripple>
Submit Form
</button>
<button class="ripple-btn ripple-btn--emerald" data-ripple>
Confirm Action
</button>
</div>
<div class="button-row">
<button class="ripple-btn ripple-btn--ghost" data-ripple>
Ghost Ripple
</button>
<button class="ripple-btn ripple-btn--rose ripple-btn--lg" data-ripple>
Large Button
</button>
<button class="ripple-btn ripple-btn--amber ripple-btn--sm" data-ripple>
Small
</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useCallback, type CSSProperties, type ReactNode, type MouseEvent } from "react";
interface RippleItem {
id: number;
x: number;
y: number;
size: number;
}
interface RippleButtonProps {
children: ReactNode;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
variant?: "blue" | "purple" | "emerald" | "rose" | "ghost" | "amber";
size?: "sm" | "md" | "lg";
className?: string;
}
const variantStyles: Record<string, CSSProperties> = {
blue: { background: "#3b82f6", color: "#fff", boxShadow: "0 4px 14px rgba(59,130,246,0.35)" },
purple: { background: "#8b5cf6", color: "#fff", boxShadow: "0 4px 14px rgba(139,92,246,0.35)" },
emerald: { background: "#10b981", color: "#fff", boxShadow: "0 4px 14px rgba(16,185,129,0.35)" },
rose: { background: "#f43f5e", color: "#fff", boxShadow: "0 4px 14px rgba(244,63,94,0.35)" },
ghost: { background: "transparent", color: "#cbd5e1", border: "1.5px solid #334155" },
amber: { background: "#f59e0b", color: "#0a0a0a", boxShadow: "0 4px 14px rgba(245,158,11,0.35)" },
};
const sizeStyles: Record<string, CSSProperties> = {
sm: { padding: "0.625rem 1.5rem", fontSize: "0.8125rem" },
md: { padding: "0.875rem 2rem", fontSize: "0.9375rem" },
lg: { padding: "1.125rem 2.75rem", fontSize: "1.0625rem" },
};
let rippleId = 0;
export function RippleButton({
children,
onClick,
variant = "blue",
size = "md",
className = "",
}: RippleButtonProps) {
const [ripples, setRipples] = useState<RippleItem[]>([]);
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const rippleSize = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - rippleSize / 2;
const y = e.clientY - rect.top - rippleSize / 2;
const id = ++rippleId;
setRipples((prev) => [...prev, { id, x, y, size: rippleSize }]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, 600);
onClick?.(e);
},
[onClick]
);
const btnStyle: CSSProperties = {
position: "relative",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "10px",
fontWeight: 600,
letterSpacing: "0.01em",
cursor: "pointer",
border: "none",
outline: "none",
overflow: "hidden",
transition: "transform 0.15s ease, box-shadow 0.2s ease",
...sizeStyles[size],
...variantStyles[variant],
};
const rippleColor = variant === "ghost" ? "rgba(148,163,184,0.2)" : "rgba(255,255,255,0.35)";
return (
<>
<style>{`
@keyframes ripple-expand {
0% { transform: scale(0); opacity: 1; }
100% { transform: scale(4); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ripple-span { display: none !important; }
}
`}</style>
<button
onClick={handleClick}
className={className}
style={btnStyle}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-1px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
}}
>
{children}
{ripples.map((r) => (
<span
key={r.id}
className="ripple-span"
style={{
position: "absolute",
borderRadius: "50%",
background: rippleColor,
width: r.size,
height: r.size,
left: r.x,
top: r.y,
transform: "scale(0)",
animation: "ripple-expand 0.6s ease-out forwards",
pointerEvents: "none",
}}
/>
))}
</button>
</>
);
}
export default function RippleButtonDemo() {
return (
<div
style={{
minHeight: "100vh",
background: "#0a0a0a",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "2rem",
padding: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
<h2 style={{ fontSize: "1.5rem", fontWeight: 700, color: "#e2e8f0" }}>Ripple Buttons</h2>
<p style={{ color: "#525252", fontSize: "0.875rem" }}>
Click the buttons to see the ripple effect
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "2rem", justifyContent: "center" }}>
<RippleButton variant="blue">Click Me</RippleButton>
<RippleButton variant="purple">Submit Form</RippleButton>
<RippleButton variant="emerald">Confirm Action</RippleButton>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "2rem", justifyContent: "center" }}>
<RippleButton variant="ghost">Ghost Ripple</RippleButton>
<RippleButton variant="rose" size="lg">
Large Button
</RippleButton>
<RippleButton variant="amber" size="sm">
Small
</RippleButton>
</div>
</div>
);
}<script setup>
import { ref } from "vue";
const buttons = [
{ label: "Click Me", variant: "blue", size: "md" },
{ label: "Submit Form", variant: "purple", size: "md" },
{ label: "Confirm Action", variant: "emerald", size: "md" },
{ label: "Ghost Ripple", variant: "ghost", size: "md" },
{ label: "Large Button", variant: "rose", size: "lg" },
{ label: "Small", variant: "amber", size: "sm" },
];
const variantStyles = {
blue: { background: "#3b82f6", color: "#fff", boxShadow: "0 4px 14px rgba(59,130,246,0.35)" },
purple: { background: "#8b5cf6", color: "#fff", boxShadow: "0 4px 14px rgba(139,92,246,0.35)" },
emerald: { background: "#10b981", color: "#fff", boxShadow: "0 4px 14px rgba(16,185,129,0.35)" },
rose: { background: "#f43f5e", color: "#fff", boxShadow: "0 4px 14px rgba(244,63,94,0.35)" },
ghost: { background: "transparent", color: "#cbd5e1", border: "1.5px solid #334155" },
amber: { background: "#f59e0b", color: "#0a0a0a", boxShadow: "0 4px 14px rgba(245,158,11,0.35)" },
};
const sizeStyles = {
sm: { padding: "0.625rem 1.5rem", fontSize: "0.8125rem" },
md: { padding: "0.875rem 2rem", fontSize: "0.9375rem" },
lg: { padding: "1.125rem 2.75rem", fontSize: "1.0625rem" },
};
function btnStyle(variant, size) {
return {
position: "relative",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "10px",
fontWeight: 600,
letterSpacing: "0.01em",
cursor: "pointer",
border: "none",
outline: "none",
overflow: "hidden",
transition: "transform 0.15s ease, box-shadow 0.2s ease",
...sizeStyles[size],
...variantStyles[variant],
};
}
function rippleColor(variant) {
return variant === "ghost" ? "rgba(148,163,184,0.2)" : "rgba(255,255,255,0.35)";
}
const ripples = ref([]);
let rippleId = 0;
function handleClick(e, variant) {
const rect = e.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
const id = ++rippleId;
ripples.value.push({ id, x, y, size, variant });
setTimeout(() => {
ripples.value = ripples.value.filter((r) => r.id !== id);
}, 600);
}
function lift(e) {
e.currentTarget.style.transform = "translateY(-1px)";
}
function drop(e) {
e.currentTarget.style.transform = "translateY(0)";
}
</script>
<template>
<div class="demo">
<h2 class="demo-title">Ripple Buttons</h2>
<p class="hint">Click the buttons to see the ripple effect</p>
<div class="row">
<button
v-for="btn in buttons.slice(0, 3)"
:key="btn.variant"
:style="btnStyle(btn.variant, btn.size)"
@click="(e) => handleClick(e, btn.variant)"
@mouseenter="lift"
@mouseleave="drop"
>
{{ btn.label }}
<span
v-for="r in ripples.filter((r) => r.variant === btn.variant)"
:key="r.id"
class="ripple-span"
:style="{
background: rippleColor(btn.variant),
width: r.size + 'px',
height: r.size + 'px',
left: r.x + 'px',
top: r.y + 'px',
}"
/>
</button>
</div>
<div class="row">
<button
v-for="btn in buttons.slice(3)"
:key="btn.variant"
:style="btnStyle(btn.variant, btn.size)"
@click="(e) => handleClick(e, btn.variant)"
@mouseenter="lift"
@mouseleave="drop"
>
{{ btn.label }}
<span
v-for="r in ripples.filter((r) => r.variant === btn.variant)"
:key="r.id"
class="ripple-span"
:style="{
background: rippleColor(btn.variant),
width: r.size + 'px',
height: r.size + 'px',
left: r.x + 'px',
top: r.y + 'px',
}"
/>
</button>
</div>
</div>
</template>
<style>
@keyframes ripple-expand {
0% { transform: scale(0); opacity: 1; }
100% { transform: scale(4); opacity: 0; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #f1f5f9;
min-height: 100vh;
}
.demo {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
padding: 2rem;
}
.demo-title { font-size: 1.5rem; font-weight: 700; color: #e2e8f0; }
.hint { color: #525252; font-size: 0.875rem; }
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2rem;
}
.ripple-span {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple-expand 0.6s ease-out forwards;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.ripple-span { display: none !important; }
}
</style><script>
let ripples = [];
let rippleId = 0;
const buttons = [
{ label: "Click Me", variant: "blue", size: "md" },
{ label: "Submit Form", variant: "purple", size: "md" },
{ label: "Confirm Action", variant: "emerald", size: "md" },
{ label: "Ghost Ripple", variant: "ghost", size: "md" },
{ label: "Large Button", variant: "rose", size: "lg" },
{ label: "Small", variant: "amber", size: "sm" },
];
const variantStyles = {
blue: "background:#3b82f6;color:#fff;box-shadow:0 4px 14px rgba(59,130,246,0.35);",
purple: "background:#8b5cf6;color:#fff;box-shadow:0 4px 14px rgba(139,92,246,0.35);",
emerald: "background:#10b981;color:#fff;box-shadow:0 4px 14px rgba(16,185,129,0.35);",
rose: "background:#f43f5e;color:#fff;box-shadow:0 4px 14px rgba(244,63,94,0.35);",
ghost: "background:transparent;color:#cbd5e1;border:1.5px solid #334155;",
amber: "background:#f59e0b;color:#0a0a0a;box-shadow:0 4px 14px rgba(245,158,11,0.35);",
};
const sizeStyles = {
sm: "padding:0.625rem 1.5rem;font-size:0.8125rem;",
md: "padding:0.875rem 2rem;font-size:0.9375rem;",
lg: "padding:1.125rem 2.75rem;font-size:1.0625rem;",
};
function btnStyle(variant, size) {
return [
"position:relative;display:inline-flex;align-items:center;justify-content:center;",
"border-radius:10px;font-weight:600;letter-spacing:0.01em;cursor:pointer;",
"border:none;outline:none;overflow:hidden;transition:transform 0.15s ease,box-shadow 0.2s ease;",
sizeStyles[size],
variantStyles[variant],
].join("");
}
function rippleColor(variant) {
return variant === "ghost" ? "rgba(148,163,184,0.2)" : "rgba(255,255,255,0.35)";
}
function handleClick(e, variant) {
const rect = e.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
const id = ++rippleId;
ripples = [...ripples, { id, x, y, size, variant }];
setTimeout(() => {
ripples = ripples.filter((r) => r.id !== id);
}, 600);
}
function lift(e) {
e.currentTarget.style.transform = "translateY(-1px)";
}
function drop(e) {
e.currentTarget.style.transform = "translateY(0)";
}
</script>
<style>
@keyframes ripple-expand {
0% { transform: scale(0); opacity: 1; }
100% { transform: scale(4); opacity: 0; }
}
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
:global(body) {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #f1f5f9;
min-height: 100vh;
}
.demo {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
padding: 2rem;
}
.demo-title { font-size: 1.5rem; font-weight: 700; color: #e2e8f0; }
.hint { color: #525252; font-size: 0.875rem; }
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2rem;
}
.ripple-span {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple-expand 0.6s ease-out forwards;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.ripple-span { display: none !important; }
}
</style>
<div class="demo">
<h2 class="demo-title">Ripple Buttons</h2>
<p class="hint">Click the buttons to see the ripple effect</p>
<div class="row">
{#each buttons.slice(0, 3) as btn}
<button
style={btnStyle(btn.variant, btn.size)}
on:click={(e) => handleClick(e, btn.variant)}
on:mouseenter={lift}
on:mouseleave={drop}
>
{btn.label}
{#each ripples.filter(r => r.variant === btn.variant && ripples.indexOf(r) === ripples.findIndex(x => x.id === r.id)) as r (r.id)}
<span
class="ripple-span"
style="background:{rippleColor(btn.variant)};width:{r.size}px;height:{r.size}px;left:{r.x}px;top:{r.y}px;"
/>
{/each}
</button>
{/each}
</div>
<div class="row">
{#each buttons.slice(3) as btn}
<button
style={btnStyle(btn.variant, btn.size)}
on:click={(e) => handleClick(e, btn.variant)}
on:mouseenter={lift}
on:mouseleave={drop}
>
{btn.label}
{#each ripples.filter(r => r.variant === btn.variant && ripples.indexOf(r) === ripples.findIndex(x => x.id === r.id)) as r (r.id)}
<span
class="ripple-span"
style="background:{rippleColor(btn.variant)};width:{r.size}px;height:{r.size}px;left:{r.x}px;top:{r.y}px;"
/>
{/each}
</button>
{/each}
</div>
</div>Ripple Button
A button that creates an expanding circular ripple wave from the exact point where the user clicks, inspired by Material Design’s touch feedback.
How it works
- On click, JavaScript calculates the cursor position relative to the button
- A
<span>element is injected at that position with a CSS animation - The
@keyframes rippleanimation scales the circle outward while fading opacity - After the animation completes, the span is removed from the DOM
Customization
- Color: Change the ripple span’s
backgroundfor different ripple colors - Duration: Adjust
animation-durationfor faster or slower ripples - Size: The ripple auto-sizes to the button dimensions using
Math.max(width, height)
When to use it
- Any clickable button or interactive element
- Form submit buttons
- Navigation actions where tactile feedback improves UX