A Step-by-Step Guide for Implementing Ripple Effect in React

Tom
8 min readFeb 21, 2019

--

Let’s get straightforward. You heard about Material Design, and you would like to apply the ripple effect in your own React apps. Of course there are libraries such as material-ui already implemented them, but you don’t really feel like using one of them. You just want a single <Ripple> component which you can apply to other elements (a button, a switch, a list item, or anything).

even completely blank, if you like

So here is what I’ve created, react-touch-ripple. It is light-weighted, easy to use, and you can use it anywhere you want for your convenience. If you don’t care how it works, you can stop reading now and grab it from npm.

For folks who are ready to take a deeper dive into this, I am going to show you how I craft this step by step. Obviously not every single detail will be covered here but you can always check out the source code if you’d like so.

What will we make?

We will split into two components: Ripple and RippleWrapper.

The Ripple component is the little circle thing itself. It will appear when you click somewhere (apparently we want it appear in the exact position where you click) and scatter after a short period of time.

The RippleWrapper component is basically, a wrapper. It is a container for Ripple (s), since you can have mutiple ripple component if you are clicking fast. Besides, we will be binding all the listeners on the RippleWrapper component so it takes the responsibility to handle all the logic.

Relationship between Ripple (spans in the TransitionGroup) and RippleWrapper

The <Ripple>

The Ripple component is quite simple. It has two stages: enter and exit.

  • In the enter stage the size of the ripple is becoming larger so we will apply transform: scale(0) and transform: scale(1)to it. At the same time, we also increase the transparency of it by appling opacity: 0 to opacity: 0.3.
  • In the exit stage the only thing we care about is transparency so setting opacity: 0 and we are done.

Therefore, we can write some CSS pretty much like these:

.ripple-entering {
opacity: 0.3;
transform: scale(1);
animation: ripple-enter 500ms cubic-bezier(0.4, 0, 0.2, 1)
}
.ripple-exiting {
opacity: 0;
animation: ripple-exit 500ms cubic-bezier(0.4, 0, 0.2, 1);
}

@keyframes ripple-enter {
0% { transform: scale(0); }
100% { transform: scale(1); }
}

@keyframes ripple-exit {
0% { opacity: 1; }
100% { opacity: 0; }
}

The way we can control this in a React component is react-transition-group. What it does is when the Ripple component mount it will trigger the onEnter event which we can take control and perform animations from there. By the way react-transition-groupis basically the only dependency we use besides react .

class Ripple extends React.Component {
state = {
rippleEntering: false,
rippleExiting: false,
};

handleEnter = () => {
this.setState({ rippleEntering: true, });
}

handleExit = () => {
this.setState({ rippleExiting: true, });
}

render () {
const { className, rippleX, rippleY, rippleSize, color, timeout, ...other } = this.props;
const { rippleExiting, rippleEntering } = this.state;

return (
<Transition
onEnter={this.handleEnter}
onExit={this.handleExit}
timeout={timeout}
{...other}
>
<span className={wrapperExiting ? 'ripple-exiting' : ''}>
<span
className={rippleEntering ? 'ripple-entering' : ''}
style={{
width: rippleSize,
height: rippleSize,
top: rippleY - (rippleSize / 2),
left: rippleX - (rippleSize / 2),
backgroundColor: color,
}}
/>
</span>
</Transition>
);
}
}

Ripple accepts three important props: rippleX , rippleY (which are the position of where it should appears) and rippleSize which indicates the sheer volume of the component. These props are calculated in the RippleWrapper component so next up we are going to talk about how to figure them out.

You can definitely write more robust code than this but as I mentioned earlier I am only giving you the gist or the main structure of my implementation.

The <RippleWrapper>

Event Handling

First off we bind events we are interested in.

class RippleWrapper extends React.Component {
state = {
rippleArray: [],
nextKey: 0
};

handleMouseDown = (e) => { this.start(e); }
handleMouseUp = (e) => { this.stop(e); }
handleMouseLeave = (e) => { this.stop(e); }
handleTouchStart = (e) => { this.start(e); }
handleTouchEnd = (e) => { this.stop(e); }
handleTouchMove = (e) => { this.stop(e); }

render () {
<TransitionGroup
component="span"
enter
exit
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
onMouseLeave={this.handleMouseLeave}
onTouchStart={this.handleTouchStart}
onTouchEnd={this.handleTouchEnd}
onTouchMove={this.handleTouchMove}
>
{this.state.rippleArray}
</TransitionGroup>
}
}

When the two events mousedown or touchstart triggers, we need to create a new ripple. So we will call this.start . When the rest four events triggers, it means the ripple has to be removed, so we will just call this.stop .

So some of you might ask: these events (at least some of them) actually trigger on the Ripple component not directly on the RippleWrapper , are we still gonna capture them? Yes, because of event propagation. An event will trigger on the ancestors of the target element in the same nesting hierarchy so the event handlers we set on the RippleWrapper will eventually fire up and everything will be fine, are they. Actually, no. Relying on event bubbling lead us to a huge yet very tricky bug and we will discuss it later.

start() and stop()

In the start function we have to complete three tasks:

  1. Calculate the coordinates of where exactly the event happens and later pass them to Ripple as rippleX and rippleY .
  2. Deside how big the ripple should be and pass it to Ripple as rippleSize
  3. Create the ripple using the factors we just calculated.
function start (e) {
const element = ReactDOM.findDOMNode(this);
const rect = element
? element.getBoundingClientRect()
: {
left: 0,
right: 0,
width: 0,
height: 0,
};
let rippleX, rippleY, rippleSize;
// 1. calculate rippleX and rippleY
const clientX = e.clientX ? e.clientX : e.touches[0].clientX;
const clientY = e.clientY ? e.clientY : e.touches[0].clientY;
rippleX = Math.round(clientX - rect.left);
rippleY = Math.round(clientY - rect.top);
// 2. calculate ripple size
const sizeX = Math.max(Math.abs((element ? element.clientWidth : 0) - rippleX), rippleX) * 2 + 2;
const sizeY = Math.max(Math.abs((element ? element.clientHeight : 0) - rippleY), rippleY) * 2 + 2;
rippleSize = Math.sqrt(Math.pow(sizeX, 2) + Math.pow(sizeY, 2));
// 3. create ripple
this.createRipple({ rippleX, rippleY, rippleSize });
}

Note that as for rippleX and rippleY we want a sort of “relative” coordinates so e.clientX and e.clientY value can’t be used directly since they are “absolute” coordinates. What we do instead is subtract them by the absolute coordinates of the RippleWrapper itself. (we can get those coordinates with getBoundingClientRect)

As for stop , there is nothing much to say. The only thing we are doing is removing the first element from rippleArray .

function stop (e) {
const { rippleArray } = this.state;
if (rippleArray && rippleArray.length) {
this.setState({
rippleArray: rippleArray.slice(1),
});
}
}

createRipple()

Now we can finally put them all together and create a Ripple component and append it to our rippleArray .

function createRipple (params) {
const { rippleX, rippleY, rippleSize } = params;
let { rippleArray } = this.state

rippleArray = [
...rippleArray,
<Ripple
key={this.state.nextKey}
rippleX={rippleX}
rippleY={rippleY}
rippleSize={rippleSize}
/>
];

this.setState({
rippleArray: rippleArray,
nextKey: this.state.nextKey + 1,
});
}

Note for the nextKey here. React ask us to have an unique key to every list item to gain better efficiency for the reconciler. So we will increase nextKey by 1 every time we create a ripple.

The Bug

As I mentioned before, there is a critical bug if you rely on event bubbling. That is, when you click the element at a faster speed, the “onclick” event never fires up.

“mousedown” triggers correctly, but “onclick” never does.

At first I thought it was something to do with the event bubbling, but later when I did some debugging and found out the event never fired up, I realized it was a browser behavior, which had something to do with the event mechanism.

Without a doubt I turned to w3 working draft trying to figure out under what condition would this “onclick” event triggers. And here is what w3 gave us:

The click event MAY be preceded by the mousedown and mouseup events on the same element, disregarding changes between other node types (e.g., text nodes).

That is to say, mousedown and mouseup has to be triggered on the same element before onclick fires up. And things we have been through is, when we click the element fast, we actually triggers mousedown on the previous Ripple , which is destroyed shortly afterwards, and then mouseup triggers on the current Ripple . They were triggered on different elements and as an result, onclick just won’t fire up.

As for the solution, this issue can be solved by adding one single line of CSS code on the Ripple element:

pointer-events: none;

Why does this work? When pointer-events set to none :

The element is never the target of pointer events; however, pointer events may target its descendant elements if those descendants have pointer-events set to some other value. In these circumstances, pointer events will trigger event listeners on this parent element as appropriate on their way to/from the descendant during the event capture/bubble phases.

Therefore, all of the events fire up on the RippleWrapper component and that is totally ok with us since we are handling these events on RippleWrapper anyway.

The End

So that’s basiclly how to create the material design ripple effect with React. But if you are implementing this in real world, there are something else you should take care of.

  • touch events can happen blazingly fast sometime, which means some Ripple will be stop() even before it is created and displayed. So adding a timeout or debounceis needed.
  • The touchstart event comes up with mousedown . So a touch on the touch device will create two Ripples. To fix this we need a flag to ignore mousedown .

And that’s pretty much it! There are certainly some details you should notice but I will not go with them in this post. If you’re interested you can always refer to the source code on github. Leave a star if you like it. Thanks.

--

--