React: Add a ripple effect to buttons
The difference between good user experience and a great one is in the details. Small animations can give the user invaluable feedback by acknowledging their actions.
We have all been to at least one website where we have clicked a button and after a second of having no feedback had a 'did that work?' moment. We then precede to click the button 600 times in the next few seconds before crashing the whole site ๐คฏ.
Having a ripple effect on a button was made famous by Material UI - when the user clicks the button, a small ripple effect expands out from the point the user clicks allowing them to see that their click did indeed register.
The Code
Lets start off with the code for our Basic button - here we are using Typescript and CSS modules but it should be easy to adapt it for your codebase.
Button.tsx
import React from "react";
import styles from "./Button.module.css";
export const Button = (
props: React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>
) => {
const { children, onClick, ...rest } = props;
return (
<button type="button" onClick={onClick} className={styles.btn} {...rest}>
<span className={styles.content}>{children}</span>
</button>
);
};
Button.module.css
.btn {
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background-color: #3857e3;
font-weight: medium;
color: #fff;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
overflow: hidden;
position: relative;
padding: 8px 16px;
font-size: 0.875rem;
}
.btn:hover {
filter: brightness(115%);
cursor: pointer;
}
/* Make sure content sits on top of ripple */
.content {
z-index: 2;
}
The Ripple Element
For the ripple, we will use an absolutely positioned span element. This will appear when the user clicks and will disappear after the animation is finished.
Lets start with creating our ripple container and span elements, which will be added and removed using an isRippling
variable. We will set the position of the element using the x
and y
coordinates of the users click which we will calculate later:
return (
<button
type="button"
onClick={handleClick}
className={styles.btn}
{...rest}
>
<span className={styles.content}>{children}</span>
{isRippling && (
<div className={styles["btn-ripple-container"]}>
<span
className={styles["btn-ripple"]}
style={{
left: x,
top: y,
}}
/>
</div>
)}
</button>
)
Now, we need to add the corresponding styles to our CSS file in order to add the ripple effect.
There are 3 parts to this:
1) The container - this has an absolute position and is the same size as our button.
2) The keyframe animation - this gives the span the ripple effect. It will transition in and out using opacity and will expand outwards linearly
3) The ripple span - initially this has 0 width and height and is absolutely positioned. It then uses the keyframe animation to animate when it is rendered.
.btn-ripple {
position: absolute;
top: 50%; /* Sensible fallback value for position */
left: 50%;
transform: translate(-50%, -50%); /* Center element when it has width and height */
opacity: 0;
width: 0; /* Start width and height */
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
animation: ripple 0.4s ease-in; /* Call ripple animation */
}
/* Container element that fills button. */
.btn-ripple-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
@keyframes ripple {
0% {
opacity: 0;
}
25% {
opacity: 1; /* Fade the ripple in and out */
}
100% {
width: 200%; /* Increase the width to make the ripple effect expand */
padding-bottom: 200%;
opacity: 0;
}
}
Next we need to write some javascript to determine the following:
When to render the ripple element, triggering the ripple effect
Where to render the ripple element
We will exctract this logic to a hook to keep our code tidy. This hook should do the following:
On a click event, take the
x
andy
location of where the user clicked and set this in stateThen set the variable
isRippling
to true and use a timeout to reset it to false once the ripple has finishedReturn
isRippling
, thex
andy
coordinates of the click and thehandleRippleOnClick
method
const useRippling = () => {
// Have state of X and Y coordinates. When not rippling, the coords = -1
const [{ x, y }, setCoordinates] = React.useState({ x: -1, y: -1 });
// Set isRippling to true when coordinates are set
const isRippling = x !== -1 && y !== -1;
// On click, set coordinates to location of click
const handleRippleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const { left, top } = e.currentTarget.getBoundingClientRect();
setCoordinates({
x: e.clientX - left,
y: e.clientY - top,
});
// Wait for ripple to finish then set coordinates back to -1
// This will make isRippling to false
setTimeout(() => {
setCoordinates({ x: -1, y: -1 });
}, 300);
};
return {
x,
y,
handleRippleOnClick,
isRippling,
};
};
Lastly, we can call this hook in our app and integrate it with the onClick Method of our button:
export const Button = (
props: React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>
) => {
const { children, onClick, ...rest } = props;
const { x, y, handleRippleOnClick, isRippling } = useRippling();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
handleRippleOnClick(e);
onClick && onClick(e);
};
return (
<button
type="button"
onClick={handleClick}
className={styles.btn}
{...rest}
>
<span className={styles.content}>{children}</span>
{isRippling && (
<div className={styles["btn-ripple-container"]}>
<span
className={styles["btn-ripple"]}
style={{
left: x,
top: y,
}}
/>
</div>
)}
</button>
);
};
And thats it! We now have a fully working ripple effect on our button ๐. Our Button.tsx
and Button.module.css
files should now look like this:
Button.tsx
import React from "react";
import styles from "./Button.module.css";
const useRippling = () => {
const [{ x, y }, setCoordinates] = React.useState({ x: -1, y: -1 });
const isRippling = x !== -1 && y !== -1;
const handleRippleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const { left, top } = e.currentTarget.getBoundingClientRect();
setCoordinates({
x: e.clientX - left,
y: e.clientY - top,
});
setTimeout(() => {
setCoordinates({ x: -1, y: -1 });
}, 300);
};
return {
x,
y,
handleRippleOnClick,
isRippling,
};
};
export const Button = (
props: React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>
) => {
const { children, onClick, ...rest } = props;
const { x, y, handleRippleOnClick, isRippling } = useRippling();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
handleRippleOnClick(e);
onClick && onClick(e);
};
return (
<button
type="button"
onClick={handleClick}
className={styles.btn}
{...rest}
>
<span className={styles.content}>{children}</span>
{isRippling && (
<div className={styles["btn-ripple-container"]}>
<span
className={styles["btn-ripple"]}
style={{
left: x,
top: y,
}}
/>
</div>
)}
</button>
);
};
Button.module.css
.btn {
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background-color: #3857e3;
font-weight: medium;
color: #fff;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
overflow: hidden;
position: relative;
padding: 8px 16px;
font-size: 0.875rem;
}
.content {
z-index: 2;
}
.btn:hover {
filter: brightness(115%);
cursor: pointer;
}
.btn-ripple {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
animation: ripple 0.4s ease-in;
}
.btn-ripple-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
@keyframes ripple {
0% {
opacity: 0;
}
25% {
opacity: 1;
}
100% {
width: 200%;
padding-bottom: 200%;
opacity: 0;
}
}
Subscribe to my newsletter
Read articles from Jack Wilkinson directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by