Amazing Button Click Animation With Framer Motion's New Animation Sequences
... Almost ready!
Background
You may have come across the Twitter-like animation where sparkles fly all over the screen when you click the like button. It’s a visually pleasing effect that has been replicated by many online. Most of the time, this is done using sprite animation as it’s the most practical solution.
However, with the introduction of Framer Motion’s sequence animations,
you now have an alternative option to build similar animations. In this tutorial, we’ll be recreating the same animation using Framer Motion’s latest animation sequences.
For the design, I drew inspiration from a tweet by Jamie Samman, who created the video below.
A little DCS update: there's now a ✨motion✨ tag for studios in the animation and motion design space.
— Jamie Samman (@jamiesamman) April 9, 2023
Any studios you know of that are doing awesome animated work, post them in the thread below. Or if you'd like to be featured drop me a line!https://t.co/CCWy5xdd1I pic.twitter.com/DV4hNEwzgy
Start off with a basic button
Before we can add any animation, we need to create a basic button. That button looks as follows
function App() { return ( <button className="relative rounded-full border-2 border-blue-600 px-6 py-2 text-2xl text-blue-600 transition-colors hover:bg-blue-100"> Motion </button> );}
export default App;
After studying Jamie’s video, the first animation we want to create involves animating the letters individually by making them move up one by one. To achieve this, we need to be able to select each letter individually. This requires wrapping each class in its own span.
function App() { return ( <button className="relative rounded-full border-2 border-blue-600 px-6 py-2 text-2xl text-blue-600 transition-colors hover:bg-blue-100"> <span className="sr-only">Motion</span> <span aria-hidden> {["M", "o", "t", "i", "o", "n"].map((letter, index) => ( <span key={`${letter}-${index}`}>{letter}</span> ))} </span> </button> );}
export default App;
Accessibility
You’ll notice that the word “Motion” is used twice: once with a class of sr-only
,
and once as an array, with each letter rendered individually as an span.
The sr-only
text is included to aid users with visual impairments in understanding the button’s meaning.
If the word appeared twice on the button, it would be confusing for them.
Text with the sr-only
class is read out only to screen readers and is not visible to typical users.
The letters, on the other hand, have an aria-hidden
attribute, ensuring that they are not read out to screen readers.
When rendering the above code, we have something that looks like this:
Let’s add some animations
Now that we have individual spans for each letter, we can finally begin animating them
using Framer Motion’s useAnimate()
hook.
import { useAnimate } from "framer-motion";
//...const [scope, animate] = useAnimate();//...
The useAnimate()
hook returns an array that provides us with two new variables: scope
and animate
.
We should set scope
as a ref on the parent element of the elements we want to animate, or on the element itself.
// focus(4,7)import { useAnimate } from "framer-motion";
function App() { const [scope, animate] = useAnimate();
return ( <div ref={scope}> <button className="relative rounded-full border-2 border-blue-600 px-6 py-2 text-2xl text-blue-600 transition-colors hover:bg-blue-100"> <span className="sr-only">Motion</span> <span aria-hidden> {["M", "o", "t", "i", "o", "n"].map((letter, index) => ( <span key={`${letter}-${index}`}>{letter}</span> ))} </span> </button> </div> );}
export default App;
After doing that, we can use the animate
function to run animations on any child.
The animate
function accepts 3 arguments as input, if you only would want to animate one element. That would look like:
animate(".selector-here", { x: 100, y: 100 }, { duration: 1 });
The first argument of the animate function is a JavaScript query selector, selecting an element that’s a child of your ref. The second argument specifies the properties you want to animate. And finally the third argument allows you to configure the animation settings. Check out the Framer Motion docs for more details.
However, in our case, we want to use Animation Sequences. This feature allows us to animate multiple children at specific times by defining when the animations should occur.
Animation sequences
Animation sequences might sound complex, but they are actually quite simple to use.
Instead of adding a single animation to the animate
function, we’ll be passing an array of animations:
animate([ [".selector-here", { x: 100, y: 100 }, { duration: 1 }], [".another-selector-here", { x: 100, y: 100 }, { duration: 1 }],]);
To make it easier to target our letters, let’s add the letter
class to our spans.
Additionally, we’ll need to add the inline-block
class, since we’ll be moving the letters up
with a translateY
transform, which isn’t possible with inline elements (which spans are by default).
<span className="letter inline-block" key={`${letter}-${index}`}> {letter}</span>
After doing that, we can finally add a click handler to our button, and run the animate function when the button is clicked.
import { useAnimate } from "framer-motion";
function App() { const [scope, animate] = useAnimate();
const onButtonClick = () => {// run animation here.};
return (
<div ref={scope}> <button onClick={onButtonClick} className="relative rounded-full border-2 border-blue-600 px-6 py-2 text-2xl text-blue-600 transition-colors hover:bg-blue-100" > <span className="sr-only">Motion</span> <span aria-hidden> {["M", "o", "t", "i", "o", "n"].map((letter, index) => ( <span className="letter inline-block" key={`${letter}-${index}`}> {letter} </span> ))} </span> </button></div>); }
export default App;
Now — lets’s finally add the animations! There actually isn’t a lot to it!
animate([[".letter", { y: -48 }, { duration: 0.2 }]]);
The above code targets all the letters, and moves them 48 pixels up. If we run that on click, we get the button below. Click it!
Great work! Now, let’s take it a step further and make the letters move up one by one using Framer Motion’s stagger
function.
The stagger function allows us to apply a delay to each letter, which results in a cascading effect.
In this case, we’ll apply a delay of 0.05 seconds to each letter.
import { stagger } from "framer-motion";
animate([[".letter", { y: -48 }, { duration: 0.2, delay: stagger(0.05) }]]);
That would look like this:
These letters somehow mirror themselves?
In Jamie’s video, you may have noticed that when a letter reaches the top of the button, it appears to come back from the bottom as if it’s passing through a portal.
This effect can be achieved by rendering the letters twice and wrapping them in a span that’s the height of one letter. By doing this, only one letter is visible at a time, giving the illusion that the letter is leaving at the top and coming back at the bottom.
One thing we don’t want to do though, is duplicate the letters again. So instead of duplicating them, we’re actually gonna use a pseudo element to render the letters again.
<span className="block h-8 overflow-hidden" aria-hidden> {["M", "o", "t", "i", "o", "n"].map((letter, index) => ( <span data-letter={letter} className="letter relative inline-block h-8 leading-8 after:absolute after:left-0 after:top-full after:h-8 after:content-[attr(data-letter)]" key={`${letter}-${index}`} > {letter} </span> ))}</span>
On the first line you see a wrapper div that’s the exact same height as one letter: h-8
.
The third highlight adds a pseudo element, that’s positioned absolute, at the bottom of the first letter.
The real magic happens in the second highlighted line (data-letter={letter}
) and the last className: after:content-[attr(data-letter)]
.
This class uses the attr() CSS function to render its pseudo-element content based on the data-letter attribute of the parent element.
This means we don’t need to duplicate the letters in the code to get the effect of letters leaving the top and coming back at the bottom.
If we briefly remove the overflow hidden from the first line, the result looks like this:
If we then add the overflow back, you see that it looks like the the letters leave at the top, and come back in from the bottom!
Let’s make that button bounce
That wraps up part one of the animation! Next up, is making the button bounce at the same time the letters animation.
Like explained before, we can simply add another animation by adding a second array:
animate([ [".letter", { y: -48 }, { duration: 0.2 }], // Scale down ["button", { scale: 0.8 }, { duration: 0.1 }], // And scale back up ["button", { scale: 1 }, { duration: 0.1 }],]);
Running this shows one odd thing though. The letters animate first, and then the button bounces.
Using the at property
The reason for that is because by default the animation sequence runs every step after the previous one.
We can let one step start at the same time as the previous one by using the at: "<"
property.
// focus(4)animate([ [".letter", { y: -48 }, { duration: 0.2 }], // Scale down ["button", { scale: 0.8 }, { duration: 0.1, at: "<" }], // And scale back up ["button", { scale: 1 }, { duration: 0.1 }],]);
Clicking the button multiple times
When clicking the button multiple times, you now notice that the button bounces, but somehow it doesn’t animate the letters again. The reason for that is because we move the letters to -48 pixels, but never reset them.
animate([ [".letter", { y: -48 }, { duration: 0.2 }], ["button", { scale: 0.8 }, { duration: 0.1, at: "<" }], ["button", { scale: 1 }, { duration: 0.1 }], // Using 0 as a value would make framer motion skip this animation. [".letter", { y: 0 }, { duration: 0.00001 }],]);
Clicking the above button lets the letters jump around though. This happens because the bounce of the button starts at the same time the first animation start.
Then once that finishes, the third animation starts (button scale back up), and then the letter reset starts. So in other words, because the button scale as at: "<"
,
we now don’t wait for all the letters to complete, before resetting them. We can force a specific time when the letters reset, by adding for example: at: 0.5
.
[".letter", { y: 0 }, { duration: 0.00001, at: 0.5 }],
Let’s bring in those stars
To add the stars to our animation, we’ll need to pull in an SVG. Or maybe we can add 20 of them… ;)
To ensure that the stars do not block clicking on the rest of the page (or the button), we add pointer-events-none
to the wrapping span and position them absolute under the button (using -z-10
).
We use the Array.from({length: 20}).map()
method to render the SVG image 20 times. Although this may seem like it could harm performance, since we’re only animating the stars when a specific button is clicked, there won’t be thousands of them on the page.
Finally notice the className on the SVG: sparkle-${index}
. This is how we’ll target the SVGs in the animation.
import { useAnimate, stagger } from "framer-motion";
export const App = () => { const [scope, animate] = useAnimate();
const onButtonClick = () => { animate([ [".letter", { y: -48 }, { duration: 0.2, delay: stagger(0.05) }], ["button", { scale: 0.8 }, { duration: 0.1, at: "<" }], ["button", { scale: 1 }, { duration: 0.1 }], [".letter", { y: 0 }, { duration: 0.00001, at: 0.5 }], ]); };
return (
<div ref={scope}> <button onClick={onButtonClick} className="relative rounded-full border-2 border-blue-600 px-6 py-2 text-2xl text-blue-600 transition-colors hover:bg-blue-100" > <span className="sr-only">Motion</span> <span className="block h-8 overflow-hidden" aria-hidden> {["M", "o", "t", "i", "o", "n"].map((letter, index) => ( <span data-letter={letter} className="letter relative inline-block h-8 leading-8 after:absolute after:left-0 after:top-full after:h-8 after:content-[attr(data-letter)]" key={`${letter}-${index}`}> {letter} </span> ))} </span> <span aria-hidden className="pointer-events-none absolute inset-0 -z-10 block" > {Array.from({ length: 20 }).map((_, index) => ( <svg className={`absolute left-1/2 top-1/2 sparkle-${index}`} key={index} viewBox="0 0 122 117" width="10" height="10" > <path className="fill-blue-600" d="M64.39,2,80.11,38.76,120,42.33a3.2,3.2,0,0,1,1.83,5.59h0L91.64,74.25l8.92,39a3.2,3.2,0,0,1-4.87,3.4L61.44,96.19,27.09,116.73a3.2,3.2,0,0,1-4.76-3.46h0l8.92-39L1.09,47.92A3.2,3.2,0,0,1,3,42.32l39.74-3.56L58.49,2a3.2,3.2,0,0,1,5.9,0Z" /> </svg> ))} </span> </button> </div>
);};
Bringing all of this together would look as follows. If you take a good look, you should spot 20 stars right on top of on another, in the center of the button. That’s the position the stars will shoot out from.
Animating the stars
Now we have 20 stars in our button, and all of them have a classname, it’s finally time to animate them. We do however want to add some randomness to this. And adding randomness in a single animation is not possible in Framer Motion.
So instead of rendering one animation, we’ll be adding 20 of them. But we won’t repeat out own code 20 times of course! We’ll be using generators this time. Take a look at the following code:
// This is a small helper function to generate a random number.// You can define this function outside your component.const randomNumberBetween = (min: number, max: number) => { return Math.floor(Math.random() * (max - min + 1) + min);};
// This should be inside your onClickhandler.const sparkles = Array.from({ length: 20 });const sparklesAnimation = sparkles.map((_, index) => [ `.sparkle-${index}`, { x: randomNumberBetween(-100, 100), y: randomNumberBetween(-100, 100), scale: randomNumberBetween(1.5, 2.5), opacity: 1, }, { duration: 0.4, at: "<", },]);
First we create an array of 20 empty items in the sparkles
variable. The only reason we do this, is so we can
loop 20 times and create a new array. Since in a second we also need to create the exit animations, I want to reuse the sparkles
variable and not run Array.from()
multiple times. This is a very small optimisation though.
After that we use this variable to create a new array with 20 animations in it. In that animation we target each sparkle invidivually,
set a random x, y and scale, and use at
to run them all at the same time.
These 20 animations, can then be spread into our animate
function like so:
animate([ [".letter", { y: -48 }, { duration: 0.2, delay: stagger(0.05) }], ["button", { scale: 0.8 }, { duration: 0.1, at: "<" }], ["button", { scale: 1 }, { duration: 0.1 }], ...sparklesAnimation, [".letter", { y: 0 }, { duration: 0.000001 }],]);
The only thing we then need to add is make sure our sparkles have an opacity of 0 by adding the opacity-0
class.
And if we then click the button, we see our sparkles shoot out in random directions! 🤩
We also have to clean up behind ourselves
The animation won’t reset itself yet though. Let’s add another animation, that fades the stars out.
const sparklesFadeOut = sparkles.map((_, index) => [ `.sparkle-${index}`, { opacity: 0, scale: 0, }, { duration: 0.3, at: "<", },]);
These we should then add as the very last step in our animation:
animate([ [".letter", { y: -48 }, { duration: 0.2, delay: stagger(0.05) }], ["button", { scale: 0.8 }, { duration: 0.1, at: "<" }], ["button", { scale: 1 }, { duration: 0.1 }], ...sparklesAnimation, [".letter", { y: 0 }, { duration: 0.000001 }], ...sparklesFadeOut,]);
And with that small change, our sparkles also fade out!
But wait — what’s going on there?
Did you click that button multiple times? The stars seem to move around.. That’s because right now we’re fading out the stars, but leave them at their x and y position. So next time they don’t animate from the center anymore, but animate from the position they were left.
The fix? One more animation!
const sparklesReset = sparkles.map((_, index) => [ `.sparkle-${index}`, { x: 0, y: 0, }, { duration: 0.000001, },]);
Adding this animation at the start of our sequence ensures that when the animation starts, all sparkles are immediately reset to their original position.
We do this at the start, because a user might click the button multiple times very fast. So if we would add it at the end, the animation might not run before the next one runs.
The end result
That was actually the final thing we needed to do to get this animation to work! Looking at the final code there isn’t actually too much to it. The animation sequences really make it a breeze to add these step by step animations.
If you’re still unsure about things, definitely watch the video too. There might be some extra tips and tricks for you in there!
The final code
import { stagger, useAnimate, animate } from "framer-motion";
const randomNumberBetween = (min: number, max: number) => {return Math.floor(Math.random() \* (max - min + 1) + min);};
type AnimationSequence = Parameters<typeof animate>[0];
function App() { const [scope, animate] = useAnimate();
const onButtonClick = () => { const sparkles = Array.from({ length: 20 }); const sparklesAnimation: AnimationSequence = sparkles.map((\_, index) => [ `.sparkle-${index}`, { x: randomNumberBetween(-100, 100), y: randomNumberBetween(-100, 100), scale: randomNumberBetween(1.5, 2.5), opacity: 1, }, { duration: 0.4, at: "<", }, ]);
const sparklesFadeOut: AnimationSequence = sparkles.map((_, index) => [ `.sparkle-${index}`, { opacity: 0, scale: 0, }, { duration: 0.3, at: "<", }, ]);
const sparklesReset: AnimationSequence = sparkles.map((_, index) => [ `.sparkle-${index}`, { x: 0, y: 0, }, { duration: 0.000001, }, ]);
animate([ ...sparklesReset, [".letter", { y: -32 }, { duration: 0.2, delay: stagger(0.05) }], ["button", { scale: 0.8 }, { duration: 0.1, at: "<" }], ["button", { scale: 1 }, { duration: 0.1 }], ...sparklesAnimation, [".letter", { y: 0 }, { duration: 0.000001 }], ...sparklesFadeOut, ]);
};
return (
<div ref={scope}> <button onClick={onButtonClick} className="relative rounded-full border-2 border-blue-600 px-6 py-2 text-2xl text-blue-600 transition-colors hover:bg-blue-100" > <span className="sr-only">Motion</span> <span className="block h-8 overflow-hidden" aria-hidden> {["M", "o", "t", "i", "o", "n"].map((letter, index) => ( <span data-letter={letter} className="letter relative inline-block h-8 leading-8 after:absolute after:left-0 after:top-full after:h-8 after:content-[attr(data-letter)]" key={`${letter}-${index}`}> {letter} </span> ))} </span> <span aria-hidden className="pointer-events-none absolute inset-0 -z-10 block" > {Array.from({ length: 20 }).map((\_, index) => ( <svg className={`absolute left-1/2 top-1/2 opacity-0 sparkle-${index}`} key={index} viewBox="0 0 122 117" width="10" height="10"> <path className="fill-blue-600" d="M64.39,2,80.11,38.76,120,42.33a3.2,3.2,0,0,1,1.83,5.59h0L91.64,74.25l8.92,39a3.2,3.2,0,0,1-4.87,3.4L61.44,96.19,27.09,116.73a3.2,3.2,0,0,1-4.76-3.46h0l8.92-39L1.09,47.92A3.2,3.2,0,0,1,3,42.32l39.74-3.56L58.49,2a3.2,3.2,0,0,1,5.9,0Z" /> </svg> ))} </span> </button> </div> );}
export default App;