Creating a Button With a Ripple Effect

3 years ago I created this UI experiment on CodePen, for a call to action with a ripple effect like ones based on material design. For this post, I’ll share how to achieve a similar effect.

This is the final result: https://codesandbox.io/s/material-button-vanilla-xf9pi

In order to achieve this effect, we will rely on CSS animations for the actual ripple but will have to use JavaScript to obtain the relative mouse position after the button is clicked. These relative coordinates will be used by the CSS through CSS variables.

I will demonstrate this component in vanilla JavaScript, but stay tuned until the end if you are interested in this component in React.

Getting Started

Let’s start off with the simplest of buttons:

<button class="btn">Click Anywhere</button>

We will give the button some basic CSS for resetting its appearance across browsers, center the text and give it some life. We will use a fixed width and height and center the text horizontically and vertically using Flexbox.

I would normally not hard-code the width and just give it some padding, to conform to dynamic text, but the reason will be explained later on. Just trust me for now. 😇

.btn {
  /* Reset styles */
  appearance: none;
  outline: none;
  background: none;
  border: none;

  width: 12em;
  height: 3em;

  /* Center text */
  display: flex;
  justify-content: center;
  align-items: center;

  padding: 0.5em 1em;
  font-size: 20px;
  border-radius: 0.3em;
  background: #42a5f5;
  color: #e3f2fd;
}

Let’s add some shadow for a more material design-y look. I usually go for a big soft shadow, stacked with a tighter more profound shadow. We will add a transition for a nice animation.

Note that animating box-shadow is not GPU-accelerated (like opacity and transform) so it may cause some UI jank. Use with caution!

.btn {
  /* Reset styles */
  appearance: none;
  outline: none;
  background: none;
  border: none;

  width: 12em;
  height: 3em;

  /* Center text */
  display: flex;
  justify-content: center;
  align-items: center;

  padding: 0.5em 1em;
  font-size: 20px;
  border-radius: 0.3em;
  background: #42a5f5;
  color: #e3f2fd;

  transition: box-shadow 250ms ease;}

.btn:hover {  box-shadow: 2px 5px 5px 0px rgba(2, 119, 11, 0.2), /* soft shadow */  1px 2px 3px 0 rgba(2, 119, 11, 0.1); /* harsh shadow */}

Adding the Ripple

We will a div for our ripple, which we will place before the text. Our ripple will essentially be a circle which should expand after clicking anywhere on the button, fading in and out.

We will also surround the text with a div and give them appropriate z-index values, so that the ripple does not obscure the text.

<button class="btn">
  <div class="btn__ripple"></div>  <div class="btn__text">Click Anywhere</div></button>

We will want to supply our ripple with coordinates to be placed relatively to the button. We will mock this for now in the CSS and add this functionality later.

First, we will add a position: relative to the button so we can add a position: absolute to the ripple and position it relatively to the button (a position: absolute element will be positioned relative to its first ancestor that is relative-ly positioned).

We will use CSS variables to mock our mouse coordinates when the button is pressed. If you have not yet heard of CSS variables (or “custom properties”), you should! They are very useful for many things, adhere to the cascade, and the browser support is pretty good!

The CSS variables will be added to the button element and not the ripple, since they will be cascaded to all of its inner elements (the ripple being one of them), but as a general habit I tend to scope the variables to the component, in case they will be needed in other children of the element. You don’t have to do this if you think otherwise.

We will also add an overflow: hidden to the button to obscure the ripple that goes out of its frame.

We will give the ripple the same amount for width and height with a border-radius of 50% to make it a nice circle. This is why it was important that we set hard-coded width and height to the button before — going for relative dimensions here could make for a “stretched” oval which wouldn’t look as nice. For not getting ripples too small for the containing button, I went for hard-coded values in both.

Finally, we will set the left and top properties according to the CSS variables on the button, which we mocked. For the left property we will substract half of the ripple’s width, to center it. We will do the same for the top property, but with the circle’s height.

.btn {
  /* Mock the CSS variables (temporary) */  --left: 12px;  --top: 24px;
  /* Reset styles */
  appearance: none;
  outline: none;
  background: none;
  border: none;

  position: relative;  width: 12em;
  height: 3em;

  /* Center text */
  display: flex;
  justify-content: center;
  align-items: center;

  padding: 0.5em 1em;
  font-size: 20px;
  border-radius: 0.3em;
  background: #42a5f5;
  color: #e3f2fd;

  transition: box-shadow 250ms ease;

  overflow: hidden;}

.btn:hover {
  box-shadow: 2px 5px 5px 0px rgba(2, 119, 11, 0.2), /* soft shadow */ 1px 2px
      3px 0 rgba(2, 119, 11, 0.1); /* harsh shadow */
}

.btn__ripple {  position: absolute;  /* We offset half of the circle's radius to center it */  left: calc(var(--left) - 2em);  top: calc(var(--top) - 2em);  width: 4em;  height: 4em;  background: #64b5f6;  border-radius: 50%;  z-index: 0;}.btn__text {  z-index: 1;}

Adding Functionality

Now we will receive the coordinates of the mouse when clicking the button, and propagate them to the CSS variables.

We will add some IDs for the button and the ripple:

<button id="btn" class="btn">  <div id="ripple" class="btn__ripple"></div>  <div class="btn__text">Click Anywhere</div>
</button>

And an event listener for mouse clicks:

const $button = document.getElementById("btn");
const $ripple = document.getElementById("ripple");

$button.addEventListener("click", e => {
  // Let's handle the click!
});

Now, to calculate the relative position of the mouse from the top left corner of the button, we will use the pageX and pageY properties on the mouse event from the callback, which will give us the absolute coordinates of the mouse relative to the viewport. For the relative left position from the button, we will substract the button’s DOM element’s offset from the left side of the viewport - its offsetLeft. Same goes for the top relative position - we will subtract the button’s offsetTop.

const $button = document.getElementById("btn");
const $ripple = document.getElementById("ripple");

$button.addEventListener("click", e => {
  const left = e.pageX - $button.offsetLeft;
  const top = e.pageY - $button.offsetTop;

  // Now what?
});

After we have these values, what’s left is passing these to our button. How would we do that? Well, with the CSS variables we mocked earlier! Now we’ll pass them the actual values.

const $button = document.getElementById("btn");
const $ripple = document.getElementById("ripple");

$button.addEventListener("click", e => {
  const left = e.pageX - $button.offsetLeft;
  const top = e.pageY - $button.offsetTop;

  $button.style.setProperty("--left", left + "px");
  $button.style.setProperty("--top", top + "px");
});

Great! Now let’s remove the mock.

Adding Animations ✨

To get a growing ripple, we will want to have 2 layered animations:

  1. A scaling animation, with the ripple going from a scale of 0 to a scale of 1.
  2. A fade animation, with the ripple going from an opacity of 0, to an opacity of 1 (or some other value) and back to 0.

For the animation, we will use a good ol’ CSS animation. But there’s a problem — we will need to somehow reset this animation every time the button is clicked.

So to actually trigger the animation, we will use a trick I learned back in the day from this fantastic CSS-Tricks article — we have a class that sets off the animation and when we click the button - we will remove the class, trigger a reflow and add the class back.

The magic line that triggers the reflow basically computes a layout property on the DOM node, which therefore gives us a “window” of sorts, in which we can remove and then add back the class responsible for the animation.

It should look something like this:

const $button = document.getElementById("btn");
const $ripple = document.getElementById("ripple");

$button.addEventListener("click", e => {
  const left = e.pageX - $button.offsetLeft;
  const top = e.pageY - $button.offsetTop;

  $button.style.setProperty("--left", left + "px");
  $button.style.setProperty("--top", top + "px");

  $ripple.classList.remove("ripple");  void $ripple.offsetWidth; // Force layout refresh  $ripple.classList.add("ripple");});

Now, let’s add the actual animations:

.btn {
  /* ... */
}

.btn:hover {
  /* ... */
}

.btn__ripple {
  /* ... */
}

.btn__text {
  /* ... */
}

.btn__ripple.ripple {  animation: ripple-opacity-anim 500ms ease, ripple-scale-anim 500ms ease;}@keyframes ripple-opacity-anim {  0% {    opacity: 0;  }  70% {    opacity: 0.5;  }  100% {    opacity: 0;  }}@keyframes ripple-scale-anim {  from {    transform: scale(0.5);  }  to {    transform: scale(1);  }}

The 70% I chose for the peak time of the opacity for the ripple, and 0.5 for the peak value are personal taste and I encourage you to experiment and see what you like best!

All that’s left is scaling up the ripple; When doing this, don’t forget changing the offsets for left and top as well! Also note that the larger the ripple will be, the faster the animation will look as well, so to compensate you can make the animation play for a bit longer.

React Component

For the React component, the styles would be the same, and the component would look something like this:

function MaterialButton({ children }) {
  const buttonRef = React.useRef();
  const [isAnimating, setIsAnimating] = React.useState(false);

  function handleClick(e) {
    e.persist();

    const $button = buttonRef.current;

    if (!$button) {
      setIsAnimating(false);
      return;
    }

    const left = e.pageX - $button.offsetLeft;
    const top = e.pageY - $button.offsetTop;

    $button.style.setProperty("--left", `${left}px`);
    $button.style.setProperty("--top", `${top}px`);

    setIsAnimating(false);
    setTimeout(() => setIsAnimating(true), 0);
  }

  return (
    <button ref={buttonRef} className="btn" onClick={handleClick}>
      <div className={"overlay" + (isAnimating ? " grow" : "")} />
      {children}
    </button>
  );
}

Here we use a ref for accessing the button’s DOM node and we use state to track whether there is a ripple happening. The magic for resetting the fade is queuing a microtask (the setTimeout with 0 milliseconds an argument) to disable the isAnimating flag, therefore not interrupting the animation but preparing the component for the next ripple.

Closing Notes and Code

There you have it! Your very own ripple 😊

Some things could have probably been done better but I hope that the concepts are clear and that you learned some new tricks. Before taking something like this to production, I would make sure that it works consistently across browsers. I have also used CSS variables here, which as far as I know cannot be polyfilled; Therefore, if you plan on using them, make sure that they conform to your browser support.

I hope that you enjoyed this, please reach out to me if anything seems unclear, or just to show me your awesome ripply buttons!

The final code can be found on CodeSandbox: vanilla or React.