Amazing Button Click Animation With Framer Motion's New Animation Sequences

Code on Github
🧑‍💻 🎥
... 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.

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.

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.

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;