SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/ripple-effects

7 CSS Ripple Effects

3 Pure CSS4 with JSPublished 2026-10-05Free · no sign-up

Seven ripples: the touch ripple that starts where you click, a script-free press ripple, pulse rings around a map pin, a ripple across a settings row, a theme switch that spreads in a circle, a sonar ping on a status dot and bounded ripples on round icon buttons.

01. Ripple from the Click Point

RippleJavaScript

The classic touch ripple: a circle grows from exactly where the button was pressed and fades out. Script reads the pointer position, places a span there sized to cover the button and removes it when its animation ends. Keyboard presses ripple from the centre.

View code
<button class="rp1">Add to cart</button>
.rp1 { position: relative; overflow: hidden; padding: 15px 30px; border: 0; border-radius: 10px; background: #4f46e5; color: #fff; font: 700 16px Inter, system-ui, sans-serif; cursor: pointer; }
.rp1 .rp { position: absolute; border-radius: 50%; background: rgba(255,255,255,.45); transform: scale(0); animation: rp1 .6s ease-out forwards; pointer-events: none; }
@keyframes rp1 { to { transform: scale(1); opacity: 0; } }
.rp1:focus-visible { outline: 3px solid #a5b4fc; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .rp1 .rp { display: none; } }
document.querySelector('.rp1').addEventListener('click', function (e) {
  var b = this, r = b.getBoundingClientRect(), s = Math.max(r.width, r.height) * 2, d = document.createElement('span');
  var x = e.clientX ? e.clientX - r.left : r.width / 2, y = e.clientY ? e.clientY - r.top : r.height / 2;
  d.className = 'rp'; d.style.cssText = 'width:' + s + 'px;height:' + s + 'px;left:' + (x - s / 2) + 'px;top:' + (y - s / 2) + 'px';
  b.appendChild(d); d.addEventListener('animationend', function () { d.remove(); });
});

02. CSS-Only Press Ripple

RippleNo JS

A ripple with no script: a radial gradient background is huge and transparent at rest, and on :active it snaps small with the transition removed, then grows again when the press ends. It always starts from the centre, which is fine for small buttons.

View code
<button class="rp2">Send</button>
.rp2 { padding: 14px 34px; border: 0; border-radius: 12px; background: #0e7490 radial-gradient(circle, rgba(255,255,255,.35) 10%, transparent 10.5%) center / 1500% 1500% no-repeat; color: #fff; font: 700 16px Inter, system-ui, sans-serif; cursor: pointer; transition: background-size .7s ease; }
.rp2:active { background-size: 0% 0%; transition: none; }
.rp2:focus-visible { outline: 3px solid #67e8f9; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .rp2 { transition: none; } }

03. Location Pin with Pulse Rings

RippleMap

Two rings spread out from a map pin and fade, a continuous ripple that says this is where you are. The rings are pseudo-elements with the same animation offset by half its duration, so one is always growing while the other fades.

View code
<div class="rp3" role="img" aria-label="Your location"><span class="dot"></span></div>
.rp3 { position: relative; isolation: isolate; display: grid; place-items: center; width: 140px; height: 140px; border-radius: 16px; background: linear-gradient(#e6f4ea 1px, transparent 1px) 0 0 / 20px 20px, linear-gradient(90deg, #e6f4ea 1px, transparent 1px) 0 0 / 20px 20px, #f6fbf7; }
.rp3 .dot { position: relative; width: 16px; height: 16px; border: 3px solid #fff; border-radius: 50%; background: #2563eb; box-shadow: 0 1px 4px rgba(0,0,0,.3); }
.rp3 .dot::before, .rp3 .dot::after { content: ""; position: absolute; inset: -3px; border-radius: 50%; background: rgba(37,99,235,.35); animation: rp3 2s ease-out infinite; z-index: -1; }
.rp3 .dot::after { animation-delay: 1s; }
@keyframes rp3 { to { transform: scale(5); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .rp3 .dot::before, .rp3 .dot::after { animation: none; opacity: 0; } }

04. Ripple Across a Card

RippleCard

The same click ripple on a larger surface: a settings row that is a single button. The ripple colour is a translucent tint of the accent so it shows on the dark card, and the row's label and value stay readable through it.

View code
<button class="rp4"><span><b>Notifications</b><small>Push, email</small></span><span aria-hidden="true">&rsaquo;</span></button>
.rp4 { box-sizing: border-box; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: space-between; width: 280px; max-width: 100%; padding: 16px 18px; border: 1px solid #2a3350; border-radius: 14px; background: #141a2c; color: #e2e8f0; font: 15px Inter, system-ui, sans-serif; text-align: left; cursor: pointer; }
.rp4 span:first-child { display: grid; } .rp4 small { color: #94a3b8; }
.rp4 > span:last-child { font-size: 22px; color: #64748b; }
.rp4 .rp { position: absolute; border-radius: 50%; background: rgba(129,140,248,.3); transform: scale(0); animation: rp4 .7s ease-out forwards; pointer-events: none; }
@keyframes rp4 { to { transform: scale(1); opacity: 0; } }
.rp4:focus-visible { outline: 2px solid #818cf8; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .rp4 .rp { display: none; } }
document.querySelector('.rp4').addEventListener('click', function (e) {
  var r = this.getBoundingClientRect(), s = Math.max(r.width, r.height) * 2.2, d = document.createElement('span');
  var x = e.clientX ? e.clientX - r.left : r.width / 2, y = e.clientY ? e.clientY - r.top : r.height / 2;
  d.className = 'rp'; d.style.cssText = 'width:' + s + 'px;height:' + s + 'px;left:' + (x - s / 2) + 'px;top:' + (y - s / 2) + 'px';
  this.appendChild(d); d.addEventListener('animationend', function () { d.remove(); });
});

05. Theme Switch with Circular Reveal

RippleTheme

Switching the panel to dark mode spreads the new theme out from the toggle in a growing circle, made with clip-path: circle() on an overlay. The toggle is a real switch with role="switch" and aria-checked, and the colours change instantly for reduced motion.

View code
<div class="rp5"><div class="ov" aria-hidden="true"></div><p>Reading mode</p><button role="switch" aria-checked="false" aria-label="Dark theme"><span></span></button></div>
.rp5 { position: relative; overflow: hidden; display: flex; align-items: center; justify-content: space-between; width: 260px; max-width: 100%; height: 160px; box-sizing: border-box; padding: 20px; border: 1px solid #e4e7ec; border-radius: 16px; background: #fff; font: 700 16px Inter, system-ui, sans-serif; color: #101828; }
.rp5 .ov { position: absolute; inset: 0; background: #0f172a; clip-path: circle(0 at calc(100% - 46px) 50%); transition: clip-path .6s ease; }
.rp5.dark .ov { clip-path: circle(150% at calc(100% - 46px) 50%); }
.rp5 p, .rp5 button { position: relative; margin: 0; transition: color .3s; }
.rp5.dark p { color: #f8fafc; }
.rp5 button { width: 52px; height: 30px; padding: 3px; border: 0; border-radius: 15px; background: #d0d5dd; cursor: pointer; }
.rp5 button span { display: block; width: 24px; height: 24px; border-radius: 50%; background: #fff; transition: transform .25s; }
.rp5 [aria-checked="true"] { background: #6366f1; } .rp5 [aria-checked="true"] span { transform: translateX(22px); }
.rp5 button:focus-visible { outline: 3px solid #a5b4fc; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .rp5 .ov, .rp5 button span { transition: none; } }
var w = document.querySelector('.rp5'), b = w.querySelector('button');
b.onclick = function () { var on = b.getAttribute('aria-checked') !== 'true'; b.setAttribute('aria-checked', on); w.classList.toggle('dark', on); };

06. Sonar Ping on a Status Dot

RippleStatus

A small green dot sends out a sonar ping to show something is live, such as a deploy in progress or an agent online. The label next to it says the status in words, and the ping is a single scaled box-shadow ring on a pseudo-element.

View code
<p class="rp6"><span class="d" aria-hidden="true"></span>Deploying to production</p>
.rp6 { display: inline-flex; align-items: center; gap: 12px; margin: 0; padding: 10px 16px; border: 1px solid #1f3b2d; border-radius: 999px; background: #0f1a14; color: #bbf7d0; font: 600 14px Inter, system-ui, sans-serif; }
.rp6 .d { position: relative; width: 10px; height: 10px; border-radius: 50%; background: #22c55e; }
.rp6 .d::after { content: ""; position: absolute; inset: 0; border-radius: 50%; box-shadow: 0 0 0 0 #22c55e; animation: rp6 1.6s cubic-bezier(0,0,.2,1) infinite; }
@keyframes rp6 { to { box-shadow: 0 0 0 12px rgba(34,197,94,0); } }
@media (prefers-reduced-motion: reduce) { .rp6 .d::after { animation: none; } }

07. Bounded Ripple on Icon Buttons

RippleIcons

A toolbar of round icon buttons where the ripple is clipped to each circle. One script handles all of them through event delegation on the toolbar, and each button has an aria-label naming its action.

View code
<div class="rp7" role="toolbar" aria-label="Text formatting"><button aria-label="Bold"><b>B</b></button><button aria-label="Italic"><i>I</i></button><button aria-label="Underline"><u>U</u></button><button aria-label="Strikethrough"><s>S</s></button></div>
.rp7 { display: flex; gap: 6px; padding: 6px; border: 1px solid #e4e7ec; border-radius: 999px; background: #fff; }
.rp7 button { position: relative; overflow: hidden; width: 44px; height: 44px; border: 0; border-radius: 50%; background: transparent; color: #101828; font: 17px Georgia, serif; cursor: pointer; }
.rp7 button:hover { background: #f2f4f7; }
.rp7 .rp { position: absolute; inset: 0; border-radius: 50%; background: rgba(79,70,229,.3); transform: scale(0); animation: rp7 .45s ease-out forwards; pointer-events: none; }
@keyframes rp7 { to { transform: scale(1.4); opacity: 0; } }
.rp7 button:focus-visible { outline: 3px solid #a5b4fc; outline-offset: 1px; }
@media (prefers-reduced-motion: reduce) { .rp7 .rp { display: none; } }
document.querySelector('.rp7').addEventListener('click', function (e) {
  var b = e.target.closest('button'); if (!b) return;
  var d = document.createElement('span'); d.className = 'rp'; b.appendChild(d);
  d.addEventListener('animationend', function () { d.remove(); });
});

About these CSS Ripple Effects

Ripples are feedback

A ripple confirms that a press registered, which matters most on touch screens where there is no hover. It should start immediately and finish quickly, under about 600ms, so it never delays the action itself.

Keep it inside the shape

Give the button position: relative and overflow: hidden, then place the ripple span absolutely. The rounded corners clip it, and the span is removed when its animationend event fires.

Keyboard presses have no coordinates

A click triggered by Enter or Space reports a pointer position of zero. Detect that and start the ripple from the centre instead of the top-left corner.

Continuous ripples need restraint

Pulsing rings on a map pin or status dot loop forever, so keep them subtle and stop them under prefers-reduced-motion. The meaning (you are here, live) must also be in text.

Frequently asked questions

Can I make a ripple without JavaScript?

Yes, from the centre: the CSS-only demo animates a radial-gradient background on :active.

Does the ripple slow the button down?

No. It runs on transform and opacity, and the click handler runs before the animation starts.

Why remove the ripple span?

Each click adds one; removing it on animationend stops hundreds piling up in the DOM.