UI Components Easy
Ripple Effect
Expanding concentric ripple circles emanating from the click point on any surface, inspired by Material Design.
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;
min-height: 100vh;
background: #0a0a0a;
color: #f1f5f9;
overflow: hidden;
}
.ripple-page {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
}
/* Main ripple surface */
.ripple-surface {
position: relative;
width: min(90vw, 700px);
height: 280px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
overflow: hidden;
cursor: pointer;
display: grid;
place-items: center;
}
.ripple-content {
position: relative;
z-index: 2;
text-align: center;
pointer-events: none;
}
.ripple-title {
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.ripple-subtitle {
font-size: 1rem;
color: rgba(148, 163, 184, 0.7);
}
/* Cards row */
.ripple-cards {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.ripple-card {
position: relative;
overflow: hidden;
width: 200px;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s;
}
.ripple-card:hover {
background: rgba(255, 255, 255, 0.06);
}
.ripple-card h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: #e0e7ff;
}
.ripple-card p {
font-size: 0.8rem;
color: rgba(148, 163, 184, 0.6);
}
/* Ripple element */
.ripple-circle {
position: absolute;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(99, 102, 241, 0.4) 0%,
rgba(99, 102, 241, 0.15) 40%,
transparent 70%
);
transform: scale(0);
animation: ripple-expand 0.8s ease-out forwards;
pointer-events: none;
z-index: 1;
}
/* Concentric ring ripple */
.ripple-ring {
position: absolute;
border-radius: 50%;
border: 2px solid rgba(99, 102, 241, 0.3);
background: transparent;
transform: scale(0);
animation: ripple-ring-expand 1s ease-out forwards;
pointer-events: none;
z-index: 1;
}
@keyframes ripple-expand {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes ripple-ring-expand {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}// Ripple Effect — expanding concentric ripples from click point
(function () {
"use strict";
function createRipple(container, e) {
var rect = container.getBoundingClientRect();
var x = e.clientX - rect.left;
var y = e.clientY - rect.top;
// Calculate size to cover the entire container from click point
var maxDist = Math.max(
Math.sqrt(x * x + y * y),
Math.sqrt((rect.width - x) * (rect.width - x) + y * y),
Math.sqrt(x * x + (rect.height - y) * (rect.height - y)),
Math.sqrt((rect.width - x) * (rect.width - x) + (rect.height - y) * (rect.height - y))
);
var size = maxDist * 2;
// Main ripple fill
var ripple = document.createElement("span");
ripple.className = "ripple-circle";
ripple.style.width = size + "px";
ripple.style.height = size + "px";
ripple.style.left = x - size / 2 + "px";
ripple.style.top = y - size / 2 + "px";
container.appendChild(ripple);
// Concentric rings
for (var i = 0; i < 3; i++) {
var ring = document.createElement("span");
ring.className = "ripple-ring";
var ringSize = size * (0.5 + i * 0.3);
ring.style.width = ringSize + "px";
ring.style.height = ringSize + "px";
ring.style.left = x - ringSize / 2 + "px";
ring.style.top = y - ringSize / 2 + "px";
ring.style.animationDelay = i * 0.12 + "s";
container.appendChild(ring);
(function (el) {
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, 1200);
})(ring);
}
// Clean up main ripple
setTimeout(function () {
if (ripple.parentNode) ripple.parentNode.removeChild(ripple);
}, 900);
}
// Attach to main surface
var surface = document.getElementById("ripple-surface");
if (surface) {
surface.addEventListener("click", function (e) {
createRipple(surface, e);
});
}
// Attach to cards
var cards = document.querySelectorAll(".ripple-target");
cards.forEach(function (card) {
card.addEventListener("click", function (e) {
createRipple(card, e);
});
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ripple Effect</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="ripple-page">
<div class="ripple-surface" id="ripple-surface">
<div class="ripple-content">
<h1 class="ripple-title">Ripple Effect</h1>
<p class="ripple-subtitle">Click anywhere on this surface</p>
</div>
</div>
<div class="ripple-cards">
<div class="ripple-card ripple-target">
<h3>Card One</h3>
<p>Click me for a ripple</p>
</div>
<div class="ripple-card ripple-target">
<h3>Card Two</h3>
<p>Click me for a ripple</p>
</div>
<div class="ripple-card ripple-target">
<h3>Card Three</h3>
<p>Click me for a ripple</p>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useCallback, useRef, useEffect, useState } from "react";
interface RippleEffectProps {
color?: string;
duration?: number;
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
interface Ripple {
id: number;
x: number;
y: number;
size: number;
}
export function RippleEffect({
color = "rgba(99, 102, 241, 0.35)",
duration = 800,
children,
className = "",
style = {},
}: RippleEffectProps) {
const [ripples, setRipples] = useState<Ripple[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const nextId = useRef(0);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const maxDist = 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)
);
const size = maxDist * 2;
const id = nextId.current++;
setRipples((prev) => [...prev, { id, x, y, size }]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, duration + 200);
},
[duration]
);
// Inject keyframes once
useEffect(() => {
const id = "ripple-effect-keyframes";
if (document.getElementById(id)) return;
const style = document.createElement("style");
style.id = id;
style.textContent = `
@keyframes re-expand {
0% { transform: scale(0); opacity: 1; }
100% { transform: scale(1); opacity: 0; }
}
@keyframes re-ring {
0% { transform: scale(0); opacity: 0.6; }
100% { transform: scale(1); opacity: 0; }
}
`;
document.head.appendChild(style);
}, []);
return (
<div
ref={containerRef}
className={className}
onClick={handleClick}
style={{
position: "relative",
overflow: "hidden",
cursor: "pointer",
...style,
}}
>
{children}
{ripples.map((r) => (
<span key={r.id}>
<span
style={{
position: "absolute",
left: r.x - r.size / 2,
top: r.y - r.size / 2,
width: r.size,
height: r.size,
borderRadius: "50%",
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
transform: "scale(0)",
animation: `re-expand ${duration}ms ease-out forwards`,
pointerEvents: "none",
zIndex: 1,
}}
/>
{[0, 1, 2].map((i) => {
const ringSize = r.size * (0.5 + i * 0.3);
return (
<span
key={i}
style={{
position: "absolute",
left: r.x - ringSize / 2,
top: r.y - ringSize / 2,
width: ringSize,
height: ringSize,
borderRadius: "50%",
border: `2px solid ${color}`,
background: "transparent",
transform: "scale(0)",
animation: `re-ring ${duration * 1.25}ms ease-out ${i * 120}ms forwards`,
pointerEvents: "none",
zIndex: 1,
}}
/>
);
})}
</span>
))}
</div>
);
}
// Demo usage
export default function RippleEffectDemo() {
return (
<div
style={{
width: "100vw",
height: "100vh",
background: "#0a0a0a",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#f1f5f9",
}}
>
<RippleEffect
style={{
width: "min(90vw, 700px)",
height: 280,
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 16,
display: "grid",
placeItems: "center",
}}
>
<div
style={{ textAlign: "center", pointerEvents: "none", position: "relative", zIndex: 2 }}
>
<h1
style={{
fontSize: "clamp(1.5rem, 4vw, 2.5rem)",
fontWeight: 800,
letterSpacing: "-0.03em",
background: "linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
marginBottom: "0.5rem",
}}
>
Ripple Effect
</h1>
<p style={{ fontSize: "1rem", color: "rgba(148,163,184,0.7)" }}>
Click anywhere on this surface
</p>
</div>
</RippleEffect>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", justifyContent: "center" }}>
{["Card One", "Card Two", "Card Three"].map((title) => (
<RippleEffect
key={title}
style={{
width: 200,
padding: "1.5rem",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 12,
}}
>
<h3
style={{
fontSize: "1rem",
fontWeight: 600,
marginBottom: "0.25rem",
color: "#e0e7ff",
position: "relative",
zIndex: 2,
}}
>
{title}
</h3>
<p
style={{
fontSize: "0.8rem",
color: "rgba(148,163,184,0.6)",
position: "relative",
zIndex: 2,
}}
>
Click me for a ripple
</p>
</RippleEffect>
))}
</div>
</div>
);
}<script setup>
import { ref, onMounted } from "vue";
const color = "rgba(99, 102, 241, 0.35)";
const duration = 800;
const ripples = ref([]);
let nextId = 0;
const containerEl = ref(null);
onMounted(() => {
const id = "ripple-effect-keyframes";
if (document.getElementById(id)) return;
const style = document.createElement("style");
style.id = id;
style.textContent = `
@keyframes re-expand {
0% { transform: scale(0); opacity: 1; }
100% { transform: scale(1); opacity: 0; }
}
@keyframes re-ring {
0% { transform: scale(0); opacity: 0.6; }
100% { transform: scale(1); opacity: 0; }
}
`;
document.head.appendChild(style);
});
function handleClick(e) {
const el = containerEl.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const maxDist = 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)
);
const size = maxDist * 2;
const id = nextId++;
ripples.value.push({ id, x, y, size });
setTimeout(() => {
ripples.value = ripples.value.filter((r) => r.id !== id);
}, duration + 200);
}
function ringSize(s, i) {
return s * (0.5 + i * 0.3);
}
</script>
<template>
<div style="width:100vw;height:100vh;background:#0a0a0a;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;">
<div
ref="containerEl"
@click="handleClick"
style="position:relative;overflow:hidden;cursor:pointer;width:min(90vw,700px);height:280px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:16px;display:grid;place-items:center;"
>
<div style="text-align:center;pointer-events:none;position:relative;z-index:2;">
<h1 style="font-size:clamp(1.5rem,4vw,2.5rem);font-weight:800;letter-spacing:-0.03em;background:linear-gradient(135deg,#e0e7ff 0%,#818cf8 50%,#6366f1 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:0.5rem;">
Ripple Effect
</h1>
<p style="font-size:1rem;color:rgba(148,163,184,0.7);">Click anywhere on this surface</p>
</div>
<span v-for="r in ripples" :key="r.id">
<span
:style="{
position: 'absolute',
left: (r.x - r.size / 2) + 'px',
top: (r.y - r.size / 2) + 'px',
width: r.size + 'px',
height: r.size + 'px',
borderRadius: '50%',
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
transform: 'scale(0)',
animation: `re-expand ${duration}ms ease-out forwards`,
pointerEvents: 'none',
zIndex: 1,
}"
/>
<span
v-for="i in [0, 1, 2]"
:key="i"
:style="{
position: 'absolute',
left: (r.x - ringSize(r.size, i) / 2) + 'px',
top: (r.y - ringSize(r.size, i) / 2) + 'px',
width: ringSize(r.size, i) + 'px',
height: ringSize(r.size, i) + 'px',
borderRadius: '50%',
border: `2px solid ${color}`,
background: 'transparent',
transform: 'scale(0)',
animation: `re-ring ${duration * 1.25}ms ease-out ${i * 120}ms forwards`,
pointerEvents: 'none',
zIndex: 1,
}"
/>
</span>
</div>
</div>
</template><script>
import { onMount } from "svelte";
const color = "rgba(99, 102, 241, 0.35)";
const duration = 800;
let ripples = [];
let nextId = 0;
let containerEl;
onMount(() => {
const id = "ripple-effect-keyframes";
if (document.getElementById(id)) return;
const styleEl = document.createElement("style");
styleEl.id = id;
styleEl.textContent = `
@keyframes re-expand {
0% { transform: scale(0); opacity: 1; }
100% { transform: scale(1); opacity: 0; }
}
@keyframes re-ring {
0% { transform: scale(0); opacity: 0.6; }
100% { transform: scale(1); opacity: 0; }
}
`;
document.head.appendChild(styleEl);
});
function handleClick(e) {
if (!containerEl) return;
const rect = containerEl.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const maxDist = 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)
);
const size = maxDist * 2;
const id = nextId++;
ripples = [...ripples, { id, x, y, size }];
setTimeout(() => {
ripples = ripples.filter((r) => r.id !== id);
}, duration + 200);
}
</script>
<div style="width:100vw;height:100vh;background:#0a0a0a;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;">
<div
bind:this={containerEl}
on:click={handleClick}
style="position:relative;overflow:hidden;cursor:pointer;width:min(90vw,700px);height:280px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:16px;display:grid;place-items:center;"
>
<div style="text-align:center;pointer-events:none;position:relative;z-index:2;">
<h1 style="font-size:clamp(1.5rem,4vw,2.5rem);font-weight:800;letter-spacing:-0.03em;background:linear-gradient(135deg,#e0e7ff 0%,#818cf8 50%,#6366f1 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:0.5rem;">
Ripple Effect
</h1>
<p style="font-size:1rem;color:rgba(148,163,184,0.7);">Click anywhere on this surface</p>
</div>
{#each ripples as r (r.id)}
<span>
<span style="position:absolute;left:{r.x - r.size / 2}px;top:{r.y - r.size / 2}px;width:{r.size}px;height:{r.size}px;border-radius:50%;background:radial-gradient(circle, {color} 0%, transparent 70%);transform:scale(0);animation:re-expand {duration}ms ease-out forwards;pointer-events:none;z-index:1;" />
{#each [0, 1, 2] as i}
{@const ringSize = r.size * (0.5 + i * 0.3)}
<span style="position:absolute;left:{r.x - ringSize / 2}px;top:{r.y - ringSize / 2}px;width:{ringSize}px;height:{ringSize}px;border-radius:50%;border:2px solid {color};background:transparent;transform:scale(0);animation:re-ring {duration * 1.25}ms ease-out {i * 120}ms forwards;pointer-events:none;z-index:1;" />
{/each}
</span>
{/each}
</div>
</div>Ripple Effect
Beautiful expanding ripple circles that emanate from the exact click point on any surface. Multiple concurrent ripples supported, with smooth scale and fade-out animations.
How it works
- On click, a
<span>element is created at the cursor’s position within the container - CSS
@keyframesscales the circle from 0 to full size while fading opacity - The element self-destructs after the animation completes
- Multiple clicks create overlapping ripples for a layered water-drop effect
Customization
- Ripple color and opacity
- Animation duration and easing
- Ripple size (auto-calculated from container dimensions or fixed)
- Support for multiple concurrent ripples
When to use it
- Button click feedback
- Card interaction effects
- Background click animations
- Touch-responsive surfaces