---
name: ripple-effect
description: Use when you want "Familiar, clean, app-like" - Creates expanding circular waves after clicks or taps.
---

# Ripple Effect

> **Category:** Motion / Interaction  -  **Personality:** Familiar, clean, app-like
>
> **Best use:** Buttons, mobile UI, material design
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 2/5 - Visual impact 3/5 - Difficulty 2/5 - Performance cost 2/5

## What it is

The ripple is Material Design's signature press feedback: when you click or tap a surface, a translucent circle is born **at the exact point of contact**, expands until it covers the whole element, and fades out. It confirms the touch landed and gives the UI a soft, physical "give". Because it reads as familiar and unobtrusive, it suits any clean app context — buttons, chips, list rows, cards, FABs — and pairs with a subtle hover/focus "state layer" tint on the same surface.

The demo is a Material money app, "Pulse", where every interactive element — filled / tonal / text / icon buttons, filter chips, the balance card, the quick-action tiles, and each transaction row — ripples from wherever you press.

## Dependencies / CDN

None - pure CSS animation + vanilla JS (no library). The demo only loads display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=Sora:wght@600;700;800&display=swap" rel="stylesheet">
```

## HTML

Any element can host ripples — add the `re-ripples` class and put the visible content in a `position:relative; z-index:1` wrapper so it stays above the wave. Buttons are ideal (free keyboard + focus semantics):

```html
<button class="re-btn re-ripples"><span class="re-label">Send money</span></button>

<!-- a tappable card / list row works identically -->
<button class="re-row re-ripples">
  <span class="re-rowic">…icon…</span>
  <span class="re-rowmid"><b>Jonah Reed</b><small>Sent · Today, 9:24</small></span>
  <span class="re-rowamt">−$48.00</span>
</button>
```

## CSS

```css
/* 1) THE HOST — must clip the wave to its own shape */
.re-ripples{
  position: relative;
  overflow: hidden;                       /* circle never escapes the element */
  -webkit-tap-highlight-color: transparent;
}

/* 2) STATE LAYER — Material's hover/focus/press tint (optional but authentic) */
.re-ripples::before{
  content:""; position:absolute; inset:0; border-radius:inherit; z-index:0;
  background: var(--re-ink,#000); opacity:0; pointer-events:none;
  transition: opacity .18s ease;
}
.re-ripples:hover::before{ opacity:.06 }
.re-ripples:focus-visible::before{ opacity:.1 }
.re-ripples.is-press::before{ opacity:.18; transition:none } /* reduced-motion flash */

/* 3) THE WAVE — JS sizes & positions it, CSS animates it */
.re-drop{
  position:absolute; z-index:0; border-radius:50%; pointer-events:none;
  transform: scale(0);
  background: var(--re-ink,#000);
  opacity: var(--re-a,.3);
  will-change: transform, opacity;
  animation: re-drop var(--re-dur,.62s) cubic-bezier(.2,.62,.34,1) forwards;
}
@keyframes re-drop{
  from{ transform:scale(0); opacity:var(--re-a,.3) }
  58%{ opacity:calc(var(--re-a,.3) * .55) }
  to{ transform:scale(1); opacity:0 }
}

/* 4) Keep the label above the wave */
.re-content,.re-label{ position:relative; z-index:1 }

/* Per-surface tuning: set the ink colour + start opacity with two vars */
.re-filled{ --re-ink:#fff;     --re-a:.34 }  /* white ripple on a coloured button */
.re-row{    --re-ink:#5a43d6;  --re-a:.16 }  /* primary-tinted ripple on a light row */
```

## JavaScript

One delegated listener covers every `.re-ripples` element on the page. The wave is a `<span>` sized so a scale of `1` exactly reaches the element's **farthest corner** from the press point:

```js
(function(){
  var root = document;                         // or a scoped container
  var reduce = matchMedia('(prefers-reduced-motion: reduce)');

  function flash(host){                         // reduced-motion: instant, no wave
    host.classList.add('is-press');
    setTimeout(function(){ host.classList.remove('is-press'); }, 150);
  }

  function ripple(host, x, y){
    if(reduce.matches){ flash(host); return; }
    var r = host.getBoundingClientRect();
    var radius = Math.hypot(Math.max(x, r.width - x), Math.max(y, r.height - y));
    var ink = document.createElement('span');
    ink.className = 're-drop';
    ink.style.width = ink.style.height = (radius * 2) + 'px';
    ink.style.left = (x - radius) + 'px';
    ink.style.top  = (y - radius) + 'px';
    host.appendChild(ink);
    ink.addEventListener('animationend', function(){ ink.remove(); });
  }

  // Pointer press -> wave at the contact point (mouse / touch / pen)
  root.addEventListener('pointerdown', function(e){
    if(e.button > 0) return;                    // ignore right / middle click
    var host = e.target.closest('.re-ripples');
    if(!host) return;
    var r = host.getBoundingClientRect();
    ripple(host, e.clientX - r.left, e.clientY - r.top);
  });

  // Keyboard activation -> wave from the centre
  root.addEventListener('keydown', function(e){
    if(e.repeat || (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar')) return;
    var host = e.target.closest('.re-ripples');
    if(!host) return;
    var r = host.getBoundingClientRect();
    ripple(host, r.width / 2, r.height / 2);
  });
})();
```

## How it works

- **Spawn at the pointer.** `getBoundingClientRect()` converts the page-space `clientX/Y` into element-local `x,y`. That point is the circle's centre.
- **Size to cover.** The circle must reach the corner farthest from the press. That distance is `hypot(max(x, w-x), max(y, h-y))`; set the span to `2 × radius` square and offset it by `-radius` so its centre lands on the press point.
- **Animate cheaply.** Only `transform: scale()` and `opacity` animate — both GPU-composited, so the wave stays at 60fps. The cubic-bezier eases out fast then settles, mimicking Material's deceleration.
- **Clip to shape.** `overflow:hidden` on the host masks the square span into the element's rounded silhouette (a pill, a 16px card, a circle).
- **State layer.** The `::before` adds the small hover/focus/press tint Material surfaces carry, so the element feels alive even before the wave fires.

## Customization

- **Ink & strength:** two CSS vars per surface — `--re-ink` (colour) and `--re-a` (start opacity). White on dark/coloured buttons; the brand/primary colour at low alpha on light surfaces.
- **Speed/curve:** `--re-dur` (≈.45–.7s feels right) and the `cubic-bezier`. Slower = more "liquid", faster = snappier.
- **Origin:** centre-spawn (`w/2,h/2`) gives a uniform "pulse"; pointer-spawn (used here) feels directly touched.
- **Persist-then-release** (advanced): start the wave on `pointerdown`, hold its grown state, and only fade on `pointerup`/`pointercancel` for long-press surfaces.

## Accessibility & performance

- **Reduced motion:** check `matchMedia('(prefers-reduced-motion: reduce)')` in JS and skip the wave entirely — give an **instant** state-layer flash (`.is-press`) instead, so the press is still confirmed without animation.
- **Keyboard & focus:** hosting ripples on real `<button>`s gives Enter/Space activation and focus for free; a centre-origin wave fires on keypress, and a visible `:focus-visible` outline must remain (the tint alone is not a sufficient focus indicator).
- **Cheap:** transform+opacity only, one short-lived node per press, removed on `animationend`. No layout thrash, negligible cost.
- **Don't leak nodes:** always remove the span on `animationend` (and keep a `setTimeout` safety net in case the animation event is missed when a tab backgrounds).

## Gotchas

- **`overflow:hidden` is mandatory** on the host or the square span spills past rounded corners. Pair it with `position:relative` so the absolute span is measured against the host.
- **Keep content above the wave.** Give the visible label/content `position:relative; z-index:1`; otherwise the ripple paints over the text. The wave sits at `z-index:0`, above the `::before` state layer.
- **`border-radius:inherit` on `::before`** so the tint follows pills/circles; the wave itself is `border-radius:50%`.
- **Use `pointerdown`, not `click`** — `click` fires after release and loses the natural press-timing; `pointerdown` also unifies mouse, touch and pen. Guard `e.button > 0` to ignore right/middle clicks.
- **A transformed/filtered ancestor** becomes the containing block and can mis-place `position:fixed` children — not an issue here (the span is `absolute` inside the host), but worth knowing if you relocate the wave.
- **Global `.is-hidden{display:none}` style clashes** are easy to hit — prefix demo classes (here `re-`) so utility names like `is-hidden` don't collide with your masking class.
