When I first discovered Material Design, I was particularly inspired by its button component. It uses a ripple effect to give users feedback in a simple, elegant way.
How does this effect work? Material Design’s buttons don’t just sport a neat ripple animation, but the animation also changes position depending on where each button is clicked.
We can achieve the same result. We’ll start with a concise solution using ES6+ JavaScript, before looking at a few alternative approaches.
HTML
Our goal is to avoid any extraneous HTML markup. So we’ll go with the bare minimum:
<button>Find out more</button>
Styling the button
We’ll need to style a few elements of our ripple dynamically, using JavaScript. But everything else can be done in CSS. For our buttons, it’s only necessary to include two properties.
button {
position: relative;
overflow: hidden;
}
Using position: relative
allows us to use position: absolute
on our ripple element, which we need to control its position. Meanwhile, overflow: hidden
prevents the ripple from exceeding the button’s edges. Everything else is optional. But right now, our button is looking a bit old school. Here’s a more modern starting point:
/* Roboto is Material's default font */
@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap');
button {
position: relative;
overflow: hidden;
transition: background 400ms;
color: #fff;
background-color: #6200ee;
padding: 1rem 2rem;
font-family: 'Roboto', sans-serif;
font-size: 1.5rem;
outline: 0;
border: 0;
border-radius: 0.25rem;
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.3);
cursor: pointer;
}
Styling the ripples
Later on, we’ll be using JavaScript to inject ripples into our HTML as spans with a .ripple
class. But before turning to JavaScript, let’s define a style for those ripples in CSS so we have them at the ready:
span.ripple {
position: absolute; /* The absolute position we mentioned earlier */
border-radius: 50%;
transform: scale(0);
animation: ripple 600ms linear;
background-color: rgba(255, 255, 255, 0.7);
}
To make our ripples circular, we’ve set the border-radius
to 50%. And to ensure each ripple emerges from nothing, we’ve set the default scale to 0. Right now, we won’t be able to see anything because we don’t yet have a value for the top
, left
, width
, or height
properties; we’ll soon be injecting these properties with JavaScript.
As for our CSS, the last thing we need to add is an end state for the animation:
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}
Notice that we’re not defining a starting state with the from
keyword in the keyframes? We can omit from
and CSS will construct the missing values based on those that apply to the animated element. This occurs if the relevant values are stated explicitly — as in transform: scale(0)
— or if they’re the default, like opacity: 1
.
Now for the JavaScript
Finally, we need JavaScript to dynamically set the position and size of our ripples. The size should be based on the size of the button, while the position should be based on both the position of the button and of the cursor.
We’ll start with an empty function that takes a click event as its argument:
function createRipple(event) {
//
}
We’ll access our button by finding the currentTarget
of the event.
const button = event.currentTarget;
Next, we’ll instantiate our span element, and calculate its diameter and radius based on the width and height of the button.
const circle = document.createElement("span");
const diameter = Math.max(button.clientWidth, button.clientHeight);
const radius = diameter / 2;
We can now define the remaining properties we need for our ripples: the left
, top
, width
and height
.
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${event.clientX - (button.offsetLeft + radius)}px`;
circle.style.top = `${event.clientY - (button.offsetTop + radius)}px`;
circle.classList.add("ripple");
Before adding our span element to the DOM, it’s good practice to check for any existing ripples that might be leftover from previous clicks, and remove them before executing the next one.
const ripple = button.getElementsByClassName("ripple")[0];
if (ripple) {
ripple.remove();
}
As a final step, we append the span as a child to the button element so it is injected inside the button.
button.appendChild(circle);
With our function complete, all that’s left is to call it. This could be done in a number of ways. If we want to add the ripple to every button on our page, we can use something like this:
const buttons = document.getElementsByTagName("button");
for (const button of buttons) {
button.addEventListener("click", createRipple);
}
We now have a working ripple effect!
Taking it further
What if we want to go further and combine this effect with other changes to our button’s position or size? The ability to customize is, after all, one of the main advantages we have by choosing to recreate the effect ourselves. To test how easy it is to extend our function, I decided to add a “magnet” effect, which causes our button to move towards our cursor when the cursor’s within a certain area.
We need to rely on some of the same variables defined in the ripple function. Rather than repeating code unnecessarily, we should store them somewhere they’re accessible to both methods. But we should also keep the shared variables scoped to each individual button. One way to achieve this is by using classes, as in the example below:
Since the magnet effect needs to keep track of the cursor every time it moves, we no longer need to calculate the cursor position to create a ripple. Instead, we can rely on cursorX
and cursorY
.
Two important new variables are magneticPullX
and magneticPullY
. They control how strongly our magnet method pulls the button after the cursor. So, when we define the center of our ripple, we need to adjust for both the position of the new button (x
and y
) and the magnetic pull.
const offsetLeft = this.left + this.x * this.magneticPullX;
const offsetTop = this.top + this.y * this.magneticPullY;
To apply these combined effects to all our buttons, we need to instantiate a new instance of the class for each one:
const buttons = document.getElementsByTagName("button");
for (const button of buttons) {
new Button(button);
}
Other techniques
Of course, this is only one way to achieve a ripple effect. On CodePen, there are lots of examples that show different implementations. Below are some of my favourites.
CSS-only
If a user has disabled JavaScript, our ripple effect doesn’t have any fallbacks. But it’s possible to get close to the original effect with just CSS, using the :active pseudo-class to respond to clicks. The main limitation is that the ripple can only emerge from one spot — usually the center of the button — rather than responding to the position of our clicks. This example by Ben Szabo is particularly concise:
Pre-ES6 JavaScript
Leandro Parice’s demo is similar to our implementation but it’s compatible with earlier versions of JavaScript:
jQuery
This example use jQuery to achieve the ripple effect. If you already have jQuery as a dependency, it could help save you a few lines of code.
React
Finally, one last example from me. Although it’s possible to use React features like state and refs to help create the ripple effect, these aren’t strictly necessary. The position and size of the ripple both need to be calculated for every click, so there’s no advantage to holding that information in state. Plus, we can access our button element from the click event, so we don’t need refs either.
This React example uses a createRipple
function identical to that of this article’s first implementation. The main difference is that — as a method of the Button
component — our function is scoped to that component. Also, the onClick
event listener is now part of our JSX:
That was amazing…!!
One of the developers of the actual Material Design ripple effect wrote an article a few years ago about the trade offs of different ways to implement and animate it. That link would be a nice addition to this article.
This is so cool! Thanks for sharing!
Nice!! I’d love to see a tutorial for the Material Tooltips too! (Such as the ones used in Google Docs or YouTube), they’re neat!
I forked the Pen to make a Houdini version. Which at the moment does limit support to Chromium browsers, but it also allows us to get the effect with no extra elements or pseudos as the ripple is a
radial-gradient()
now and it also simplifies the JavaScript as we don’t need to compute the size of the ripple relative to the button.How this works:
x, y
(set as custom properties) which fills with semitransparent white of variable alpha (set as a custom property--a
) up to a variable radius (set as a custom property--r
)--a
and--r
custom properties so that we can animate them when necessary.ani
class (which it initially doesn’t), then we animate these custom properties from.7
to0
and from0%
to100%
respectively--x
and--y
variables to the coordinates of the point that was clicked relative to the button’s top left corner.ani
class on the button (this will start the CSS animation).ani
class from the buttonThis is a really interesting approach. Thank you for sharing and for taking the time to explain the most important changes!
Nice, but what about for an input instead of a button?
Nice technique, I ported it over to Svelte here. I changed a few things to make it a little closer to the official Material implementation looks-wise (adding blur, lower initial opacity, and larger initial scale to the ripple span).
I think the one thing missing here is that on mobile clientX and Y don’t exist and you need to use pageX and Y which means checking for a touch event first, great work regardless.
Hi JHeth et al.. I converted your example into a Svelte action which allows the ripple effect to be applied to anything while also making it highly configurable with CSS, CSS props, and regular props.
https://svelte.dev/repl/4430fde7d42a4b03845d81411e701702?version=3.44.3
Thanks for this nice article!
One thing I did differently is, instead of manually removing existing ripples on the next click, you can also listen for the animation to end and remove the ripple directly afterwards:
circle.addEventListener('animationend', () => button.removeChild(circle))
Nice one here thanks for sharing
Great article! This is very similar to how I created the v-wave directive for Vue. The main difference being that the ripple behaves closer to the implementation on Android in that it appears on
pointerdown
(unlesspointercancel
is fired) and stays visible until thepointerup
event is fired.Awesome work! For anyone trying to apply this to absolute positioned elements, I found using
event.offsetX
andevent.offsetY
as a more predictable code pattern.Essentially changing the
circle.style.[top|left]
calculations to:This change may only be necessary if your button’s offsetParent is not the body anchored at top/left===0.
Add
pointer-events: none;
tospan.ripple
style to avoid miscalculations of offsetX and offsetY when clicking very quickly (i.e. on the ripple)Thank you so mutch for sharing.
event.clientX
is relative to the viewport whereas button.offsetLeft is relative to the button.offsetParent. The example only worked as described in the article because ‘button’ was a direct child of body. Therefore we might usebutton.getBoundingClientRect()
to retrieve the left and top properties of the button, which are relative to the viewport. Then the code might be rewritten to:Another issue is removing and adding the span element every time
createRipple()
is invoked. Once the span element is created, the code should toggle the animation by means of a class.Replace the circle with an SVG hexagon, add a little CSS spin, et voila:
The spinning hexagonal click-spot show
Now we’re rocking larger spaces as well as buttons.
Whoops, link failed:
in VueJS use pageX pageY instead of clientX clientY
If I may point out, I could only get the React sample to work properly after changing
clientX
andclientY
topageX
andpageY
.Can someone please explain the calculations for left and top,
circle.style.left =
${event.clientX - (button.offsetLeft + radius)}px
;circle.style.top =
${event.clientY - (button.offsetTop + radius)}px
;So I’ve tried this in my website, but realised it did not work well when scrolled. I can confirm this by putting many
<br>
s before the button. The button moves down, but the ripple will still be anchored to its original position. Hence, the button has no ripple.Anyone has a CSS or HTML only solution to this issue? I have a JS issue that offsets the y axis of the ripple by the window.scrollY value. I would really appreciate some help.
You must take into consideration window.scrollX and window.scrollY. working demo https://codepen.io/giuliano-oliveira/pen/YzNYJZY
When the button’s parent is not the body, for the
element.offset
, we need something like thisconst getOffsetTop = element => {
.let offsetTop = 0;
while (element) {
offsetTop += element.offsetTop;
element = element.offsetParent;
}
return offsetTop;
};
https://medium.com/@alexcambose/js-offsettop-property-is-not-great-and-here-is-why-b79842ef7582
Thanks for this tutorial! I am using the ripple on a large white background.
The
linear
transition looks a little odd to me. The ripple is there one moment and then suddenly gone.So I prefer to use
ease-out
instead oflinear
. (Mainly for thebackground
property, but it doesn’t look too bad when it affects thetransform
too, even though ripples in the real world do not slow down.)Just a friendly reminder that this demo code wouldn’t pass WCAG:
https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html
Using
outline:0
without an alternative visible change on keyboard focus will cause this to fail the success criteria.Hi. For those of you who want to use the code snippets in the article for your own project, incorporate these two comments posted above:
– Use
offsetX
andoffsetY
(by Angelo Gonzales), and– Remove the ripple once animation is over (by Jonas).
I initially simply copy and paste the code in the article, only to find a bug. Don’t repeat what I went through.
If you use
offset
from element to get it’s position, you will face problems when you use it inside a relative parent. Instead of usingoffset
, usegetBoundingClientRect()
instead. Works like a charm! :)