What Is the CSS Ripple Effect?
Input goes in, a circle grows out from wherever the finger or cursor landed, and then it fades. That’s the whole effect, and it tells the user the press registered right where they made it.
Material Design called it the “ink ripple”, and it works as a micro-interaction that tells the user the interface registered the input.
Material Components for the web still ships it as a reusable package, although the project is no longer actively maintained. Its Button, FAB, Checkbox and Radio components all use it (MDC Web documentation).
Most ripples are bounded, meaning they’re clipped to the element edges, which is the default for buttons, cards and list rows. Unbounded ripples flow outside the element in a circle and suit icon buttons and selection controls such as checkboxes and radios (the MDC Web ripple docs and the mwc-ripple package document both modes).
A few other effects get called ripples without being one. A hover or :active color swap has no expanding shape and no origin point. A pulse or sonar loop repeats with animation-iteration-count: infinite and needs no input at all. A water ripple built from an SVG filter or WebGL distorts an image and gives no feedback on input.
The hover effects most buttons already carry only say an element is clickable. The ripple is the one that shows where the click landed.
How the Ripple Animation Works: Scale, Opacity and Timing
Two properties do the work. transform: scale() takes a small circle to full size while opacity drops to 0.
The circle starts at scale(0) on the pointer position and ends at a diameter that reaches the farthest corner of the element. Its transform-origin stays at the default center because the span itself is positioned around the click.
One CSS keyframes rule carries both changes: @keyframes ripple { to { transform: scale(1); opacity: 0; } }. An ease-out curve fits the motion, since the wave moves fast at first and settles as it fades.
Material splits the effect in two. In Jetpack Compose, a press starts a ripple animation, while hover and focus show a fixed state layer whose alpha varies by interaction (Android Developers documentation).
The html.it button tutorial uses 600 ms with linear easing, and the GeeksforGeeks ripple guide, updated July 2025, uses 1 s. On opacity, the mwc-ripple README from Material Components sets a default press value of 0.12 against 0.04 for hover.
Tutorials span 600 ms to 1 s, and the Material package gives a press three times the opacity of a hover. No source fixes one correct duration, so treat 600 ms as a starting point and tune it on a real phone.
Which event should start the ripple?
Use pointerdown for the JavaScript version and keep :active for the centered CSS version.
The click event fires after release, so on touch screens the wave starts late. pointerdown fires on contact for mouse, touch and pen, which is the closest match to Material’s press response. :active is pure CSS, but an animation tied to it stops the moment the press ends.
Which Ripple Method Fits: Pure CSS, JavaScript, Paint API or a Library?
Use a JavaScript span for most pages. Pick the pure CSS gradient only when a centered ripple is acceptable, and leave the Paint API out of production code.
| Method | Starts at click point | Needs script | Best fit |
|---|
| Pure CSS gradient | No, always centered | No | Small and icon buttons |
| JavaScript span | Yes | Yes | Cards, list rows, wide buttons |
| Paint API worklet | Yes, with coordinates passed in | Yes | Chromium-only projects |
| Framework library | Yes, by default | Yes, via the library | Projects already on that framework |
Compare the element’s width with how far a finger can land from its center. On a 40 px icon button a centered wave looks the same as a click-origin one. On a full-width list row it floats away from the tap.
Pure CSS gradient
It needs no extra DOM nodes and no script, so a stylesheet alone is enough. The catch is the origin, which stays centered because CSS cannot read pointer coordinates (finnhvman, CodePen).
JavaScript span
The JavaScript version appends a span at the pointer position on each press and deletes it afterward.
You get the true click origin on any element size, plus full control over color, duration and easing. In return you write cleanup code for every span, you need a positioned and clipped container, and spans leak when the cleanup event never fires.
CSS Paint API
The caniuse tables, checked in October 2026, list the CSS Painting API as supported from Chrome 65, Edge 79 and Opera 52, unsupported in Firefox and Safari on iOS, and disabled by default in desktop Safari.
web.dev’s Houdini guide calls Safari support partial in one sentence and lists Chromium-based browsers only in the next, while caniuse marks Safari as disabled by default. Either way the result is the same: Safari and Firefox users get no worklet without a polyfill.
That fallback is weak too. GoogleChromeLabs archived the css-paint-polyfill repository on 19 April 2026 (GitHub), so it is read-only.
On the plus side, one worklet draws the circle with a canvas-style API and reads custom properties, so it can serve many elements.
A feedback effect should work for every user, and this method fails in two of the three major engines. Skip it.
Framework library
A library handles repeated presses, keyboard focus and unbounded variants for you. The cost is a dependency and its defaults.
MUI ships a TouchRipple inside its ButtonBase component, and Material Components for the web offers the standalone @material/ripple package for plain pages, though that project is no longer actively maintained.
How to Build a Ripple With Pure CSS
A pure CSS ripple pairs an oversized radial-gradient() with a background-size change on :active. It needs no script, and the wave always starts at the center of the element.
Gradients cannot transition into one another, so the trick animates the size of a single fixed gradient instead. The technique comes from finnhvman’s CodePen write-up, which uses a 0.5 s transition, and 1stWebDesigner’s pure CSS ripple button tutorial uses a 1 s transition. The colors and the 0.8 s duration below are example values.
.ripple {
background-color: #1e88e5;
background-position: center;
transition: background 0.8s;
}
.ripple:hover {
background: #47a7f5 radial-gradient(circle, transparent 1%, #47a7f5 1%) center / 15000%;
}
.ripple:active {
background-color: #6eb9f7;
background-size: 100%;
transition: background 0s;
}
At rest the button has a solid background-color and a 0.8 s background transition. On hover the gradient is drawn at 15000%, so its transparent 1% center is larger than the button and only the hover color shows. The active state snaps it to background-size: 100% with a 0 s transition, which shrinks the transparent dot to a point.
On release, the size transition expands the dot and reveals the pressed color as a growing circle. That is why the ripple plays when the click or tap ends, not when it starts (finnhvman, CodePen).
Stop positions are easy to get wrong by hand, and a CSS gradient generator shows the circle before the code reaches the stylesheet.
The origin stays centered, and a long press shows only the small dot. Touch browsers can also keep :hover applied after a tap. A pseudo-element variant that animates ::after on :active has its own problem, since it stops the instant the press ends and a quick tap shows a fragment of the wave.
How to Start the Ripple at the Click Point With JavaScript
Create a span on pointerdown, center it on the pointer inside the element, animate it, and remove it when the animation ends.
- Give the container
position: relative and overflow: hidden, so the span is placed and clipped inside it. - Listen for
pointerdown on the element. - Read its box with
getBoundingClientRect(). - Size the span to twice the box diagonal.
- Set
left and top to the pointer position minus the box origin minus half the span size. - Remove the span on
animationend.
The stylesheet side looks like this:
.ripple-btn {
position: relative;
overflow: hidden;
}
.ripple {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: scale(0);
animation: ripple 600ms ease-out;
pointer-events: none;
}
@keyframes ripple {
to {
transform: scale(1);
opacity: 0;
}
}
And the script:
const button = document.querySelector('.ripple-btn');
button.addEventListener('pointerdown', (event) => {
const rect = button.getBoundingClientRect();
const size = Math.hypot(rect.width, rect.height) * 2;
const ripple = document.createElement('span');
ripple.className = 'ripple';
ripple.style.width = ripple.style.height = size + 'px';
ripple.style.left = event.clientX - rect.left - size / 2 + 'px';
ripple.style.top = event.clientY - rect.top - size / 2 + 'px';
button.appendChild(ripple);
ripple.addEventListener('animationend', () => ripple.remove());
});
MDN documents that rectangle values are relative to the viewport and change as the page scrolls. Because clientX uses the same viewport space, the subtraction stays correct at any scroll position.
Tutorials often size the span with the larger of width and height. A click near one corner then leaves the opposite corner uncovered, because that radius is shorter than the diagonal.
Cleanup can fail too. MDN states that animationend does not fire when an animation is aborted, for example when the element is removed.
A span whose animation never runs, such as under an animation: none rule, never fires the event either, so spans pile up in the DOM. Skip creating the span in that case.
Material Components’ ripple (version 14.0.0) passes foreground size, scale, left and top to CSS through custom properties. Copying that pattern keeps the numbers in JavaScript and the animation in the stylesheet.
Why Does the Ripple Spill Outside or Fail to Appear?
Most ripple bugs come from the container, not the animation. Missing clipping, a stacking problem or a browser clipping bug is usually behind it.
| Symptom | Cause | Fix |
|---|
| Wave spills past the edge | Container lacks overflow: hidden | Add it, with position: relative |
| Corners poke through on rounded buttons (Safari) | Animated child ignores the border-radius clip | Make the container a stacking context |
| Rendering glitches on animated spans (reported in some Safari versions) | Container uses overflow: clip | Switch to overflow: hidden |
| Label looks washed out | Span paints above the text | Raise the label with position: relative and a z-index, or lower the ripple alpha |
| Several waves layer on fast taps | Every press adds a span | Let all finish, or remove the previous span first |
Ionic documents the clipping rule from both sides. Surfaces for bounded ripples take overflow: hidden, while unbounded ones need overflow: visible so the circle can leave the box.
Rounded corners in Safari
A WebKit bug lets an animated child ignore the parent’s border-radius clip. A widely shared workaround note, tested on iOS 14.4, calls it an old bug that will never be fixed.
WebKit’s own tracker disagrees for at least one case. Bug 231360, which concerned an animated pseudo-element with translateX, was closed as “Configuration Changed”. A contributor could not reproduce it in Safari Technology Preview 165, and a WebKit engineer confirmed that an earlier border-radius and composited-layer fix had resolved it. The last change was logged on 9 March 2023.
WebKit bug 257794, still open, reports animated transforms rendering wrongly inside an overflow: clip container in Safari 16.5 and on iPhone. Changing the container to overflow: hidden removed the problem in that report.
The workarounds from the gist all force a stacking context on the clipping element:
z-index: 0 (with a positioned element)transform: translateZ(0)mask-image: radial-gradient(white, black)will-change: transform, used sparingly
The gist’s author prefers transform or z-index as the least invasive. Start with z-index: 0.
Round and irregular shapes
Circles and pills need only a border-radius on the container, which a CSS border radius generator previews quickly.
Logos and other irregular outlines need clip-path on the container instead. FreeFrontend’s gallery lists a logo ripple built this way, with the polygon points set in clip-path.
How Much Does a Ripple Cost in Performance?
Cost follows the properties being animated, not the ripple as an idea.
web.dev’s compositor guide, checked in October 2026, names two properties that skip layout and paint: transform and opacity. A span ripple animates exactly those.
The gradient method animates background-size, which sits outside that pair, so each frame repaints the element. One small button will not show it. A long list of rows will.
If profiling shows jank, promote the span and not the button. Put will-change: transform in the ripple class, because the span is created by script just before the animation and disappears afterward. Skip it when the ripple already runs smoothly.
Delegate the listener too. Attach one pointerdown handler to the list and find the pressed row with event.target.closest(), instead of one handler per row. And keep promoted layers few, since web.dev warns that every one costs memory and management.
MDN calls will-change a last resort and lists guidelines that include not applying it to too many elements and not using it as premature optimization. A stylesheet rule on every row of a long list breaks both.
How to check it in Chrome DevTools
- Open the Rendering tab and turn on Paint flashing.
- Press the ripple target and watch for green flashes, which mark repainted regions.
- Record a short trace in the Performance panel and look for Layout and Paint entries on each frame.
Layout entries during the press mean something besides transform and opacity is changing.
How to Make a Ripple Accessible
Replace the scale with a short fade when the user asks for reduced motion, and give keyboard users a focus style that does not depend on the ripple.
WCAG 2.3.3, Animation from Interactions, is a web accessibility criterion at Level AAA, introduced in WCAG 2.1. It says motion triggered by interaction can be disabled unless it is necessary to the function or the information (W3C).
MDN explains that prefers-reduced-motion: reduce means the user prefers an interface that removes, reduces or replaces motion, and it warns that scaling or panning large objects can trigger vestibular disorders. Its own example swaps a scale for a “dissolve”. Smashing Magazine’s guide on motion preferences makes the same point: reduced motion doesn’t have to mean no motion, so subtle feedback can stay.
A ripple holds both kinds of motion. The scale is motion, the fade is not, so under reduce you drop the scale and keep the fade.
@media (prefers-reduced-motion: reduce) {
.ripple {
transform: scale(1);
animation: ripple-fade 200ms linear;
}
}
@keyframes ripple-fade {
from { opacity: 0.6; }
to { opacity: 0; }
}
Place the block after the base .ripple rule. Replace the animation instead of setting it to none, so animationend still fires and the cleanup listener runs.
Keyboard focus
Enter and Space never fire pointerdown, so keyboard users see no wave at all. That leaves you writing your own focus style. MUI’s ButtonBase documentation warns that disabling the ripple leaves no default :focus-visible styling, and a custom ripple never draws a focus indicator either.
.ripple-btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
Check the label’s color contrast at the ripple’s peak opacity, because a light overlay washes out text on a mid-tone button.
How MUI, Angular Material and Vuetify Handle the Ripple
All three ship a built-in ripple, and each exposes one switch to center it and another to turn it off.
| Library | Ripple API | Centered ripple | Turn off |
|---|
| MUI | ButtonBase with TouchRipple | centerRipple | disableRipple, disableTouchRipple |
| Angular Material | matRipple directive | matRippleCentered | matRippleDisabled |
| Vuetify | v-ripple directive | center option | ripple prop on components |
In MUI, a focusRipple prop adds a keyboard focus ripple, and TouchRippleProps configures the ripple element. Angular Material launches manual ripples at given coordinates and reads global defaults from the MAT_RIPPLE_GLOBAL_OPTIONS injection token.
Vuetify’s v-btn and other components come with it built in, and the directive works on any block-level element. A helper class changes the color.
Other options
Google’s Material Web project offers an md-ripple custom element. Its GitHub tracker carries an open report of a ripple that stays visible after focus is lost inside a swiper widget.
Waves.js attaches with Waves.attach() or the waves-effect class, and then you call Waves.init(). npm lists version 0.7.6 of the node-waves package under a 2014-2018 copyright line, so treat it as a legacy choice.
Ionic’s ion-ripple-effect takes a type of bounded (the default, expanding from the click position) or unbounded (starting at the center and overflowing the container). It needs a relatively positioned parent with the ion-activatable class, and it exposes addRipple(x, y) for manual starts.
Library or custom code?
If the project already runs on one of these frameworks, use its built-in ripple, since it’s wired into every button, tab and chip.
On a plain HTML page, custom code wins. Pulling in a UI component library for one effect costs far more than the short script shown earlier.
Never stack a custom ripple on a library button. The button already has one.
CSS Ripple Effect FAQ
Which browsers support a ripple effect built with CSS and JavaScript?
The span method relies on two APIs that MDN lists as Baseline widely available: getBoundingClientRect, and animationend, which has worked in every browser since December 2019.
Only the Paint API route falls outside that.
How do I change the ripple color?
Change the background of the ripple span, using a semi-transparent value such as rgba(255, 255, 255, 0.4) so the button color shows through.
A custom property like –ripple-color lets each button override it. In the gradient version, edit the color stops instead.
Can I build the ripple with Tailwind CSS?
Yes, for the styling. Utilities handle the container (relative, overflow-hidden) and the span (absolute, rounded-full, a translucent background), and the keyframes go once into the Tailwind theme: the config file in v3, or an @theme block in v4.
The pointer-position script stays plain JavaScript, because utilities cannot read coordinates.
How do I add the ripple to a React component?
Store each ripple as an object with x, y and size in state, render one span per object, and remove it in the onAnimationEnd handler. The coordinate math matches the vanilla version.
Read the rectangle from event.currentTarget, not event.target, so a press on a child element still measures the button.
Does the ripple work on touch screens?
Yes, with one catch. Mobile browsers can draw their own tap highlight over the wave.
In WebKit and Blink browsers, set -webkit-tap-highlight-color to transparent on the element where the highlight shows. The Waves.js documentation flags the same problem for Internet Explorer on Windows Phone, and its remedy is the <meta name="msapplication-tap-highlight" content="no"/> tag.
Can I add a ripple to cards, links and menu items?
Yes, on any element that clips its children. Cards and menu items need position relative and overflow hidden.
Links are inline by default, so set display to inline-block or block first, because overflow does not clip an inline box.
What to Test Before a Ripple Goes Live
A CSS ripple effect is ready to release when keyboard users can see where focus is, reduced motion gets a fade, and the frame rate holds on a real phone. Check them in that order: the focus outline, then the reduced-motion fade, then rapid taps on a mid-range phone.
Keyboard comes first because a missing focus style blocks use, while a missing wave only costs polish. Reduced motion takes one media query, and touch behavior and paint cost need real hardware, so they come last.
Dropping the scale for reduced motion costs the wave its sense of direction, which is why the focus outline has to carry the confirmation.
The advice to use custom spans holds until Safari and Firefox both ship the CSS Painting API without a flag, and the position was verified in October 2026.
A web accessibility checklist covers the contrast and focus checks that remain.