Cover image by Linus Nylund on Unsplash
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.
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 (
span
s) 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 useLayoutEffect
fires 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
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!
Top comments (14)
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.
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?
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?
Oh yeah absolutely. You actually don't need to nest the children inside the ripple in that case. You would do something like this:
You might want to be a little concious of the event bubbling if you are using this approach.
Thanks so much!
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), [...];
Thank you so much for catching that. Fixed it.
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
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.
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!!
Thank you Rohan! I followed your guide and implemented the effect for my side project :)
Glad to hear you found it useful!
A pro article I learnt alot not about ripple workaround and also the way create a component of react.
thanks a lot.