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:
1<button class="btn">Click Anywhere</button>
html
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. π
1.btn { 2 /* Reset styles */ 3 appearance: none; 4 outline: none; 5 background: none; 6 border: none; 7 8 width: 12em; 9 height: 3em; 10 11 /* Center text */ 12 display: flex; 13 justify-content: center; 14 align-items: center; 15 16 padding: 0.5em 1em; 17 font-size: 20px; 18 border-radius: 0.3em; 19 background: #42a5f5; 20 color: #e3f2fd; 21}
css
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 (likeopacity
andtransform
) so it may cause some UI jank. Use with caution!
1.btn { 2 /* Reset styles */ 3 appearance: none; 4 outline: none; 5 background: none; 6 border: none; 7 8 width: 12em; 9 height: 3em; 10 11 /* Center text */ 12 display: flex; 13 justify-content: center; 14 align-items: center; 15 16 padding: 0.5em 1em; 17 font-size: 20px; 18 border-radius: 0.3em; 19 background: #42a5f5; 20 color: #e3f2fd; 21 22 transition: box-shadow 250ms ease; 23} 24 25.btn:hover { 26 box-shadow: 2px 5px 5px 0px rgba(2, 119, 11, 0.2), /* soft shadow */ 27 1px 2px 3px 0 rgba(2, 119, 11, 0.1); /* harsh shadow */ 28}
css
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.
1<button class="btn"> 2 <div class="btn__ripple"></div> 3 <div class="btn__text">Click Anywhere</div> 4</button>
html
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.
1.btn { 2 /* Mock the CSS variables (temporary) */ 3 --left: 12px; 4 --top: 24px; 5 6 /* Reset styles */ 7 appearance: none; 8 outline: none; 9 background: none; 10 border: none; 11 12 position: relative; 13 width: 12em; 14 height: 3em; 15 16 /* Center text */ 17 display: flex; 18 justify-content: center; 19 align-items: center; 20 21 padding: 0.5em 1em; 22 font-size: 20px; 23 border-radius: 0.3em; 24 background: #42a5f5; 25 color: #e3f2fd; 26 27 transition: box-shadow 250ms ease; 28 29 overflow: hidden; 30} 31 32.btn:hover { 33 box-shadow: 2px 5px 5px 0px rgba(2, 119, 11, 0.2), /* soft shadow */ 1px 2px 34 3px 0 rgba(2, 119, 11, 0.1); /* harsh shadow */ 35} 36 37.btn__ripple { 38 position: absolute; 39 40 /* We offset half of the circle's radius to center it */ 41 left: calc(var(--left) - 2em); 42 top: calc(var(--top) - 2em); 43 44 width: 4em; 45 height: 4em; 46 47 background: #64b5f6; 48 border-radius: 50%; 49 50 z-index: 0; 51} 52 53.btn__text { 54 z-index: 1; 55}
css
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:
1<button id="btn" class="btn"> 2 <div id="ripple" class="btn__ripple"></div> 3 <div class="btn__text">Click Anywhere</div> 4</button>
html
And an event listener for mouse clicks:
1const $button = document.getElementById("btn"); 2const $ripple = document.getElementById("ripple"); 3 4$button.addEventListener("click", e => { 5 // Let's handle the click! 6});
js
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
.
1const $button = document.getElementById("btn"); 2const $ripple = document.getElementById("ripple"); 3 4$button.addEventListener("click", e => { 5 const left = e.pageX - $button.offsetLeft; 6 const top = e.pageY - $button.offsetTop; 7 8 // Now what? 9});
js
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.
1const $button = document.getElementById("btn"); 2const $ripple = document.getElementById("ripple"); 3 4$button.addEventListener("click", e => { 5 const left = e.pageX - $button.offsetLeft; 6 const top = e.pageY - $button.offsetTop; 7 8 $button.style.setProperty("--left", left + "px"); 9 $button.style.setProperty("--top", top + "px"); 10});
js
Great! Now let's remove the mock.
Adding Animations β¨
To get a growing ripple, we will want to have 2 layered animations:
- A scaling animation, with the ripple going from a scale of
0
to a scale of1
. - A fade animation, with the ripple going from an opacity of
0
, to an opacity of1
(or some other value) and back to0
.
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:
1const $button = document.getElementById("btn"); 2const $ripple = document.getElementById("ripple"); 3 4$button.addEventListener("click", e => { 5 const left = e.pageX - $button.offsetLeft; 6 const top = e.pageY - $button.offsetTop; 7 8 $button.style.setProperty("--left", left + "px"); 9 $button.style.setProperty("--top", top + "px"); 10 11 $ripple.classList.remove("ripple"); 12 void $ripple.offsetWidth; // Force layout refresh 13 $ripple.classList.add("ripple"); 14});
js
Now, let's add the actual animations:
1.btn { 2 /* ... */ 3} 4 5.btn:hover { 6 /* ... */ 7} 8 9.btn__ripple { 10 /* ... */ 11} 12 13.btn__text { 14 /* ... */ 15} 16 17.btn__ripple.ripple { 18 animation: ripple-opacity-anim 500ms ease, ripple-scale-anim 500ms ease; 19} 20 21@keyframes ripple-opacity-anim { 22 0% { 23 opacity: 0; 24 } 25 70% { 26 opacity: 0.5; 27 } 28 100% { 29 opacity: 0; 30 } 31} 32 33@keyframes ripple-scale-anim { 34 from { 35 transform: scale(0.5); 36 } 37 to { 38 transform: scale(1); 39 } 40}
css
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:
1function MaterialButton({ children }) { 2 const buttonRef = React.useRef(); 3 const [isAnimating, setIsAnimating] = React.useState(false); 4 5 function handleClick(e) { 6 e.persist(); 7 8 const $button = buttonRef.current; 9 10 if (!$button) { 11 setIsAnimating(false); 12 return; 13 } 14 15 const left = e.pageX - $button.offsetLeft; 16 const top = e.pageY - $button.offsetTop; 17 18 $button.style.setProperty("--left", `${left}px`); 19 $button.style.setProperty("--top", `${top}px`); 20 21 setIsAnimating(false); 22 setTimeout(() => setIsAnimating(true), 0); 23 } 24 25 return ( 26 <button ref={buttonRef} className="btn" onClick={handleClick}> 27 <div className={"overlay" + (isAnimating ? " grow" : "")} /> 28 {children} 29 </button> 30 ); 31}
jsx
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.