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.
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<buttonclass="btn">Click Anywhere</button>2
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 */3appearance: none;4outline: none;5background: none;6border: none;78width:12em;9height:3em;1011/* Center text */12display: flex;13justify-content: center;14align-items: center;1516padding:0.5em1em;17font-size:20px;18border-radius:0.3em;19background:#42a5f5;20color:#e3f2fd;21}22
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 (like opacity and transform) so it may cause some UI jank. Use with caution!
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.
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;56/* Reset styles */7appearance: none;8outline: none;9background: none;10border: none;1112position: relative;13width:12em;14height:3em;1516/* Center text */17display: flex;18justify-content: center;19align-items: center;2021padding:0.5em1em;22font-size:20px;23border-radius:0.3em;24background:#42a5f5;25color:#e3f2fd;2627transition: box-shadow 250ms ease;2829overflow: hidden;30}3132.btn:hover{33box-shadow:342px5px5px0pxrgba(2,119,11,0.2),35/* soft shadow */1px2px3px0rgba(2,119,11,0.1);/* harsh shadow */36}3738.btn__ripple{39position: absolute;4041/* We offset half of the circle's radius to center it */42left:calc(var(--left)-2em);43top:calc(var(--top)-2em);4445width:4em;46height:4em;4748background:#64b5f6;49border-radius:50%;5051z-index:0;52}5354.btn__text{55z-index:1;56}57
##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:
1const $button =document.getElementById("btn");2const $ripple =document.getElementById("ripple");34$button.addEventListener("click",(e)=>{5// Let's handle the click!6});7
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");34$button.addEventListener("click",(e)=>{5const left = e.pageX- $button.offsetLeft;6const top = e.pageY- $button.offsetTop;78// Now what?9});10
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");34$button.addEventListener("click",(e)=>{5const left = e.pageX- $button.offsetLeft;6const top = e.pageY- $button.offsetTop;78 $button.style.setProperty("--left", left +"px");9 $button.style.setProperty("--top", top +"px");10});11
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 of 1.
A fade animation, with the ripple going from an opacity of 0, to an opacity of 1 (or some other value) and back to 0.
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");34$button.addEventListener("click",(e)=>{5const left = e.pageX- $button.offsetLeft;6const top = e.pageY- $button.offsetTop;78 $button.style.setProperty("--left", left +"px");9 $button.style.setProperty("--top", top +"px");1011 $ripple.classList.remove("ripple");12void $ripple.offsetWidth;// Force layout refresh13 $ripple.classList.add("ripple");14});15
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:
1functionMaterialButton({ children }){2const buttonRef =React.useRef();3const[isAnimating, setIsAnimating]=React.useState(false);45functionhandleClick(e){6 e.persist();78const $button = buttonRef.current;910if(!$button){11setIsAnimating(false);12return;13}1415const left = e.pageX- $button.offsetLeft;16const top = e.pageY- $button.offsetTop;1718 $button.style.setProperty("--left",`${left}px`);19 $button.style.setProperty("--top",`${top}px`);2021setIsAnimating(false);22setTimeout(()=>setIsAnimating(true),0);23}2425return(26<buttonref={buttonRef}className="btn"onClick={handleClick}>27<divclassName={"overlay"+(isAnimating ?"grow":"")}/>28{children}29</button>30);31}32
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.