ButtonsFree

Ripple Effect

On click, a 40% white disc is born under the pointer, grows to 4× the button's longer side and fades out in 600 ms, clipped by overflow hidden. The HTML's onclick attribute calls a createRipple function that must stay global: wrapped in a module or an IIFE, the effect breaks.

JSClickAnimation
Hover or click the scene to interact

This effect is free — the Buttons category contains 27 effects total, including 2 free. Effect.Labs has 811 vanilla effects. Explore the category →

Usage examples

Sign in Good to see you again. Email marie@exemple.fr Password •••••••••• Forgot your password?

Click the button

① Sign in — login card

WhenShort form: email, password, a full-width button.
WhyThe click ripple is the tactile feedback inherited from Material Design; on a sign-in button it confirms the press was registered while the request goes out — no spinner, no label change.
SettingsFull-width button, padding: 13px, 15 px, border-radius: 10px, #4f46e5 fill (6.3:1 with the text).
S02 · E42
Episode 42 · 38 min Designing for the thumb Reach zones, target sizes and the three gestures every mobile app relies on.
Resume at 12:40

Click the button

② Listen — podcast episode card

WhenMedia card: cover, duration, title, a play button.
WhyA play button gets clicked often and fast; the single wave — the previous one is removed on each click — stays clean under repeated clicks, and the ×4 scale covers the button from any point.
SettingsCompact button (padding: 10px 18px, 14 px), border-radius: 999px, rgba(255, 255, 255, .3) wave over .5s.
Order summary
Subtotal · 2 items€40.00
Shipping€8.00
Total€48.00
Secure payment · Free cancellation for 14 days

Click the button

③ Pay — order summary

WhenCart or payment step: total lines, a full-width buy button.
WhyOn a pay button the user wants certainty of having clicked once: the wave starts exactly from their pointer and only one wave lives at a time — a nervous double-click doesn't produce two stacked animations.
SettingsFull-width button, padding: 15px, 16 px, ease-out curve, .7s duration, #4f46e5 fill.

How it works

The HTML carries the trigger: <button class="demo-btn btn-ripple" onclick="createRipple(event)">. An inline onclick attribute is evaluated in the global scope, so the createRipple function must be a top-level declaration of the script — which is how it ships. It reads e.currentTarget (the button), its getBoundingClientRect(), and sets the wave's diameter to Math.max(clientWidth, clientHeight): 162 px for the demo button (162 × 50 measured in Chromium), radius 81.

A square <span> of 162 × 162 px is created and positioned so that its center sits under the pointer: left = e.clientX − rect.left − radius, top = e.clientY − rect.top − radius (measured: a click 20 px from the left edge gives left: −61px). Before appending it, the script looks for a .ripple-circle already inside the button and removes it: one wave at a time — two clicks 100 ms apart leave a single span. The span listens for animationend to remove itself; no setTimeout.

On the CSS side, .btn-ripple .ripple-circle — the rule is scoped under the button's class — sets position: absolute (the button is position: relative through .demo-btn), border-radius: 50%, background: rgba(255, 255, 255, .4), transform: scale(0), pointer-events: none and animation: ripple .6s linear. @keyframes ripple has only an end step, scale(4); opacity: 0: the start is the span's own state (scale 0, opacity 1). Measured: scale 0.28 at 50 ms, 2.1 at 300 ms, span removed before 700 ms. The final disc is 648 px; the overflow: hidden of .demo-btn clips it to the 12 px rounded box.

The geometry guarantees coverage: with the diameter equal to the longer side and a scale of 4, the wave covers the whole button from any click point — the worst case (a corner) needs a radius equal to the diagonal, 170 px, reached at scale 2.1, around 315 ms; from the center, 160 ms. The second half of the 600 ms is fade only. linear: constant speed, no easing. The transition: .3s on .btn-ripple has nothing to animate in the code as shipped — no :hover or :active rule — it only matters if you add one.

Accessibility

  • Nothing happens on the keyboard in Chromium and Firefox: Enter or Space on the <button> does fire click, but with clientX = clientY = 0 (and detail = 0). The wave is then born at the top-left corner of the viewport — −270 / −236 px from the demo button — and stays outside the button, clipped: no feedback. WebKit (Safari) passes the button's center and the wave works. Add at the top of the function: if (e.detail === 0) { x = rect.width / 2; y = rect.height / 2 } and use those coordinates instead of e.clientX − rect.left.
  • Borderline contrast: 16 px white text on #6366f1 = 4.47:1, 0.03 below the 4.5:1 AA threshold. #4f46e5 gives 6.3:1 without changing the wave. During the wave, the label sits on #a1a3f7 at the wavefront (40% white over indigo): 2.3:1 for a fraction of the 600 ms — a transient state WCAG doesn't evaluate, but a label that washes out; lower the wave's opacity to .25 if the text is long.
  • prefers-reduced-motion not handled. Beware the animation: none reflex: without an animation, animationend never fires and the span stays in the DOM (measured: 1 orphan span 800 ms after the click, invisible at scale 0, removed on the next click). Better to cut at the source: if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; as the first line of createRipple.
  • Screen readers: the span is empty, inserted into the <button>, without text — the accessible name doesn't change. Add circle.setAttribute('aria-hidden', 'true') to be safe. pointer-events: none guarantees the wave never intercepts the next click.
  • Focus and touch: the code sets border: none without touching outline, the native focus ring stays (not clipped by overflow: hidden). On touch, the tap produces a click with coordinates: the wave starts under the finger — that's the original Material feedback, and it works. Target ≈ 50 px tall, above the 44 px minimum.

Browser compatibility

Requires ES2015 (const, arrow function, template literals), Element.remove(), the unprefixed animationend event and a CSS animation on transform/opacity — every browser since 2016. Zero dependencies. Two integration constraints: the function must stay global (the onclick attribute requires it), and an inline attribute is refused by a Content-Security-Policy without 'unsafe-inline' — replace it with addEventListener in that case.

Chrome 49+✓ Full
Firefox 45+✓ Full
Safari 10+✓ Full
Edge 79+✓ Full
Mobile iOS✓ Full
Android Chrome✓ Full

Without JavaScript, the button is an ordinary indigo button, clickable: the onclick attribute is inert, nothing happens on click. If JavaScript is on but the function is missing or not global, every click throws ReferenceError: createRipple is not defined in the console — the button keeps its role (a submit still submits). Without CSS animations, the span is appended at scale(0), invisible, and stays until the next click.

The code

Copy the three blocks into your page. No dependencies.

HTML
<button class="demo-btn btn-ripple" onclick="createRipple(event)">Ripple Effect</button>
CSS
.demo-btn  {
   padding: 16px 32px;
   font-size: 1rem;
   font-weight: 600;
   border-width: medium;
   border-style: none;
   border-color: currentcolor;
   border-image: none;
   border-radius: 12px;
   cursor: pointer;
   font-family: inherit;
   position: relative;
   overflow: hidden;
   }

.btn-ripple  {
   background: rgb(99, 102, 241);
   color: white;
   transition: 0.3s;
   }

.btn-ripple .ripple-circle  {
   position: absolute;
   border-radius: 50%;
   background: rgba(255, 255, 255, 0.4);
   transform: scale(0);
   animation: 0.6s linear 0s 1 normal none running ripple;
   pointer-events: none;
   }

@keyframes ripple  {
   
  100%  {
   transform: scale(4);
   opacity: 0;
   }
}
JavaScript (fx-0102)
// L'attribut onclick="createRipple(event)" du bouton exige une fonction GLOBALE.
function createRipple(e) {
  const btn = e.currentTarget;
  const rect = btn.getBoundingClientRect();
  // Au clavier (Entree/Espace), clientX/clientY valent 0 : l'onde naitrait
  // hors du bouton, invisible. On la centre dans ce cas.
  const auClavier = e.detail === 0;
  const px = auClavier ? rect.left + rect.width / 2 : e.clientX;
  const py = auClavier ? rect.top + rect.height / 2 : e.clientY;
  const circle = document.createElement('span');
  const diameter = Math.max(btn.clientWidth, btn.clientHeight);
  const radius = diameter / 2;
   circle.style.width = circle.style.height = `${diameter}px`;
  circle.style.left = `${px - rect.left - radius}px`;
  circle.style.top = `${py - rect.top - radius}px`;
  circle.classList.add('ripple-circle');
   const ripple = btn.querySelector('.ripple-circle');
  if (ripple) ripple.remove();
   circle.addEventListener('animationend', () => circle.remove());
  btn.appendChild(circle);
}

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
animation-duration (CSS — .ripple-circle) 0.6s Total length, coverage + fade. .4s = snappy; 1s = slow wave. The span's removal follows animationend, nothing to change in the JS.
scale (CSS — @keyframes ripple, end step) scale(4) Final size = 4 × the longer side. The button is covered from 2.1 on: scale(2.5) ends the wave right after coverage — livelier at the same duration.
Wave background (CSS — .ripple-circle) rgba(255, 255, 255, 0.4) Color and opacity of the disc. .2 = subtle; rgba(0, 0, 0, .15) for a light button. This is the starting opacity, the fade goes down to 0.
animation-timing-function (CSS — .ripple-circle) linear Constant speed. ease-out starts fast and slows down — closer to the Material ripple, whose expansion phase lasts 225 ms.
Diameter (JS — Math.max(btn.clientWidth, btn.clientHeight)) button's longer side (162 px) Initial size of the disc before scaling. Divide by 2 for a wave that starts smaller and just covers the button at scale 4; multiply by 1.2 for a bolder start.
One wave at a time (JS — if (ripple) ripple.remove()) yes Delete these two lines to stack the waves of rapid clicks: each span removes itself on animationend, there is no leak.
background (CSS — .btn-ripple) #6366f1 Button color. #4f46e5 for AA contrast (6.3:1) with the white text; the white wave stays readable on it.

FAQ

On click, the console says “createRipple is not defined”. Why?

Because the onclick="createRipple(event)" attribute looks the function up in the global scope (window). If your script is a module (type="module", an import in a Vite or webpack bundle) or is wrapped in an IIFE, createRipple is no longer there — measured in Chromium: typeof window.createRipple is undefined and the click throws. Two fixes: expose the function (window.createRipple = createRipple), or better, drop the attribute and wire document.querySelectorAll('.btn-ripple').forEach(b => b.addEventListener('click', createRipple)) — the function can then live in any scope, and the integration passes a CSP without 'unsafe-inline'.

Nothing happens when I activate the button with the keyboard.

That's measured, not a setting: in Chromium and Firefox, the click fired by Enter or Space carries clientX = clientY = 0. The e.clientX − rect.left − radius math then places the wave at the viewport's top-left corner, far from the button, and overflow: hidden hides it entirely. Safari passes the button's center, hence the “works on my machine” impression. Fix it with e.detail === 0 (true for a synthetic click): in that case, start from the button's center, rect.width / 2 and rect.height / 2.

How do I put the ripple on several buttons, or on a link?

The function is generic: it reads e.currentTarget, so every element that calls it gets its own wave. Three conditions per element: the btn-ripple class (the .ripple-circle rule is scoped under it — without it the span has no style and stays invisible), position: relative and overflow: hidden (provided by .demo-btn). On an <a>, add display: inline-block. If you delegate the listener to a parent, e.currentTarget becomes that parent: replace it with e.target.closest('.btn-ripple').