DEV Community

loading...
Cover image for Recreating the material design ripple effect in React

Recreating the material design ripple effect in React

rohanfaiyazkhan profile image Rohan Faiyaz Khan Updated on ・6 min read

Cover image by Linus Nylund on Unsplash

Link to original post my blog

Rippling in React

We have all seen the ripple effect animation which was part of the material design recommendation. It presents itself as a circle that appears at the point of a click and then enlarges and fades away. As a UI tool, it is a fantastic and familiar way to let the user know that there has been a click interaction.

Ripple effect example

While the ripple effect is perfectly doable in Vanilla JS, I wanted a way to integrate it with my React components. The easiest way would be to use Material-UI which is a popular UI library. This is a very good idea in general if you want a solid UI library that generates UI out of the box. However for a small project it makes little sense to learn to work with a large library just to achieve one effect. I figured there had to be a way to do without a UI library.

I looked through a lot of projects implementing something similar this over Github, Codepen and Codesandbox and took inspiration from some of the best ones. The ripple effect is possible on any web framework because it is achieved through a clever bit of CSS.

For advanced readers who want to go straight to the code and skip the explanation behind it, feel free to browse it in this Code Sandbox.

This is my implementation of the CSS for this effect.

<button class="parent">
  <div class="ripple-container">
    <span class="ripple"></span>
  </div>
</button>
.parent {
  overflow: hidden;
  position: relative;
}

.parent .ripple-container {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}

.parent .ripple-container span {
  position: absolute;
  top: ...
  right: ...
  height: ...
  width: ...
  transform: scale(0);
  border-radius: 100%;
  opacity: 0.75;
  background-color: #fff;
  animation-name: ripple;
  animation-duration: 850ms;
}

@keyframes ripple {
  to {
    opacity: 0;
    transform: scale(2);
  }
}

The overflow: hidden property prevents the ripple from rippling out of the container. The ripple is a circie (border-radius: 100%) which starts at a small size and grows large as it fades out. The growing and fade out animations are achieved by manipulating transform: scale and opacity in our ripple animation.

We will however need to dynamically provide a few styles using Javascript. We need to find the positional coordinates i.e. top and left, which are based on where the user clicked, and the actual height and width, which depend on the size of the container.

So here's what our component will need to do.

  • Render an array of ripples (spans) in the container <div>
  • On mouse down, append a new ripple to the array and calculate the ripple's position and size
  • After a delay, clear the ripple array to not clutter up the DOM with old ripples
  • Optionally take in the ripple duration and color. We want to be able to customize the ripple's behaviour if needed.

Let's get started

I am using styled-components for my styles as I am comfortable with it but feel free to use whatever styling option you prefer. The first thing we will do is include the above CSS in our components.

import React from 'react'
import styled from 'styled-components'

const RippleContainer = styled.div`
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;

  span {
    transform: scale(0);
    border-radius: 100%;
    position: absolute;
    opacity: 0.75;
    background-color: ${props => props.color};
    animation-name: ripple;
    animation-duration: ${props => props.duration}ms;
  }

  @keyframes ripple {
    to {
      opacity: 0;
      transform: scale(2);
    }
  }
`;

Notice that I left the background-color and animation-duration to be fetched from props. This is so that we can dynamically set these values later in our props. Let's define those now:

import React from 'react'
import styled from 'styled-components'
import PropTypes from 'prop-types'

...

const Ripple = ({ duration = 850, color = "#fff" }) => {

  ...

}

Ripple.propTypes = {
  duration: PropTypes.number,
  color: PropTypes.string
}

export default Ripple

Next up we want to define an array for our ripples and create a function for adding ripples. Each element of the array will be an object with x, y and size properties, which are information needed to style the ripple. In order to calculate those values, we will fetch them from a mousedown event.


const Ripple = ({ duration = 850, color = "#fff" }) => {
  const [rippleArray, setRippleArray] = useState([]);

  const addRipple = (event) => {

    const rippleContainer = event.currentTarget.getBoundingClientRect();
    const size = rippleContainer.width > rippleContainer.height
                  ? rippleContainer.width
                  : rippleContainer.height;

    const x = 
      event.pageX - rippleContainer.x - rippleContainer.width / 2;
    const y = 
      event.pageY - rippleContainer.y - rippleContainer.width / 2;
    const newRipple = {
      x,
      y,
      size
    };

    setRippleArray((prevState) => [ ...prevState, newRipple]);
  }

The above code uses a bit of the Browser DOM API. getBoundClientRect() allows us to get the longest edge of the container, and the x and y coordinates relative to the document. This along with MouseEvent.pageX and MouseEvent.pageY allows us to calculate the x and y coordinates of the mouse relative to the container. If you want to learn more about how these work, there are much more detailed explanations for getBoundClientRect, MouseEvent.pageX and MouseEvent.pageY at the wonderful MDN Web Docs.

Using this, we can now render our array of ripples.

return (
    <RippleContainer duration={duration} color={color} onMouseDown={addRipple}>
      {
        rippleArray.length > 0 &&
        rippleArray.map((ripple, index) => {
          return (
            <span
              key={"ripple_" + index}
              style={{
                top: ripple.y,
                left: ripple.x,
                width: ripple.size,
                height: ripple.size
              }}
            />
          );
        })}
    </RippleContainer>
  );

RippleContainer is our styled component that takes in the duration and color as props along with our newly created addRipple as a onMouseDown event handler. Inside it we will map over all our ripples and assign our calculated parameters to their corresponding top, left, width and height styles.

With this we are done adding a ripple effect! However, there is one more small thing we will need to do with this component and that is clean the ripples after they are done animating. This is to prevent stale elements from cluttering up the DOM.

We can do this by implementing a debouncer inside a custom effect hook. I will opt for useLayoutEffect over useEffect for this. While the differences between the two merit an entire blog post of its own, it is suffice to know that useEffect fires after render and repaint while useLayoutEffectfires after render but before repaint. This is important here as we are doing something that has an immediate impact on the DOM. You can read more about this here.

Below is our custom hook's implementation and usage where we pass a callback to clear the ripple array. We use a timeout that we can reset in order to create a simple debouncer. Essentially everytime we create a new ripple, the timer will reset. Notice that the timeout duration is much bigger than our ripple duration.

import React, { useState, useLayoutEffect } from "react";

...

const useDebouncedRippleCleanUp = (rippleCount, duration, cleanUpFunction) => {
  useLayoutEffect(() => {
    let bounce = null;
    if (rippleCount > 0) {
      clearTimeout(bounce);

      bounce = setTimeout(() => {
        cleanUpFunction();
        clearTimeout(bounce);
      }, duration * 4);
    }

    return () => clearTimeout(bounce);
  }, [rippleCount, duration, cleanUpFunction]);
};

const Ripple = ({ duration = 850, color = "#fff" }) => {
  const [rippleArray, setRippleArray] = useState([]);

  useDebouncedRippleCleanUp(rippleArray.length, duration, () => {
    setRippleArray([]);
  });

  ...

Now we are done with our Ripple component. Let's build a button to consume it.

import React from "react";
import Ripple from "./Ripple";
import styled from "styled-components";

const Button = styled.button`
  overflow: hidden;
  position: relative;
  cursor: pointer;
  background: tomato;
  padding: 5px 30px;
  color: #fff;
  font-size: 20px;
  border-radius: 20px;
  border: 1px solid #fff;
  text-align: center;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
`;

function App() {
  return (
    <div className="App">
      <Button>
        Let it rip!
        <Ripple />
      </Button>
      <Button>
        Its now yellow!
        <Ripple color="yellow" />
      </Button>
      <Button>
        Its now slowwwww
        <Ripple duration={3000} />
      </Button>
    </div>
  );
}

And that's it

Example with multiple ripple containers

We now have ripples in all shades and speeds! Better yet our ripple component can reused in pretty much any container as long as they have overflow: hidden and position: relative in their styles. Perhaps to remove this dependency, you could improve on my component by creating another button that already has these styles applied. Feel free to have fun and play around with this!

Discussion

pic
Editor guide
Collapse
sannajammeh5 profile image
Sanna Jammeh

Solid guide, however there is a mistake in your code! You are setting the rippleArray with newRippleArray which is an Object. Therefore the map method is never called. You must change setRippleArray(newRipple) to setRippleArray(prevState => [...prevState, newRipple]);

Also debouncer callback is never called because you immediately return the clearTimeout, not a function that calls clearTimeout.

Change useLayoutEffect(fn..., return clearTimeout(bounce), [...]); to useLayoutEffect(fn..., return () => clearTimeout(bounce), [...];

Collapse
rohanfaiyazkhan profile image
Rohan Faiyaz Khan Author

Thank you so much for catching that. Fixed it.

Collapse
dustinkiselbach profile image
dustinkiselbach

This looks great and works well. Thank you. Is it possible to register onclick events through the ripple container? I would like to be able to click through the container so that I can register specific onclick functions to different components behind the ripple container.

Collapse
rohanfaiyazkhan profile image
Rohan Faiyaz Khan Author

Hi thank you for the response! I am not sure what you mean by clicking through the ripple container. I wasn't initially envisioning any other components inside it. Can you give me an example of what you are trying to do?

Collapse
dustinkiselbach profile image
dustinkiselbach

Basically I would like to have the ripple effect over an entire component, with sub components inside with different onclick functions. Would this be possible?

Thread Thread
rohanfaiyazkhan profile image
Rohan Faiyaz Khan Author

Oh yeah absolutely. You actually don't need to nest the children inside the ripple in that case. You would do something like this:

<ParentComponent>
   <ChildButton onClick={someHandler} />
   <Ripple />
</ParentComponent>

You might want to be a little concious of the event bubbling if you are using this approach.

Thread Thread
dustinkiselbach profile image
dustinkiselbach

Thanks so much!

Collapse
sanishkr profile image
sanish

Thanks for making this. This is working perfectly except when I have a lot elements having ripple effect and it shows animation only for elements which are visible above the fold. Once I scroll down, animation doesn't seem to be visible. Can you please look into this

Collapse
dustinkiselbach profile image
dustinkiselbach

I had the same issue. In the addRipple function, where const x is defined add - window.scrollX at the end, and - window.scrollY where const y is defined at the end. That should fix your problem.

Collapse
sanishkr profile image
sanish

Thanks for quick reply. I tried your approach, it worked. I also tried to replace event.pageX and event.pageY with event.clientX and event.clientY. Both works!!

Collapse
leireriel profile image
Leire Rico

Thank you Rohan! I followed your guide and implemented the effect for my side project :)

Collapse
rohanfaiyazkhan profile image
Rohan Faiyaz Khan Author

Glad to hear you found it useful!