Home /

Recreating the Twitter Heart Animation (with One Element, No Images, and No JavaScript)

I recently saw a recreation of the Twitter heart animation among the picks on CodePen. If I happen to have a bit of time, I always look through the code of demos that catch my attention to see if there's something in there that I could use or improve. In this case, I was surprised to see the demo was using an image sprite. I later learned that this is how Twitter does it. Surely it could be done without images, right?

I decided I'd give that a try. I also decided I'd do it without JavaScript because this is a perfect candidate for the checkbox hack, which allows you to make simple on/off toggles through form elements and clever CSS.

The result:

Now let's see how I did it!

Looking at the original sprite

It has 29 frames, a number I have no problem with, until it comes to computations. That's when it starts looking ugly to me because it's a big prime number, I can't divide it by small pretty numbers like 2, 4, or 5 and get an integer. Oh, well... that's what approximations are good for. 29 is pretty close to both 28, which is a multiple of 4 as 4 * 7 = 28, and 30, which is a multiple of 5 (5 * 6 = 30). So we could take this 29 to be either 28 or 30, whichever suits us best.

The next thing to notice about the sprite is that it has three components:

• the heart
• the bubble behind the heart
• the particles around the heart

This means that it can be done with just one element and its two pseudos. The heart is the element itself, the bubble is the ::before pseudo-element and the particles are the ::after pseudo-element.

Using the checkbox hack

The whole heart and its other parts will be the <label> of the checkbox. Clicking the label will toggle the checkbox and allow us to handle the two states. In this situation, our HTML looks like this, a checkbox and a label containing a unicode heart:

<input id="toggle-heart" type="checkbox" />
<label for="toggle-heart">❤</label>

Let's get the checkbox out of sight:

[id='toggle-heart'] {
position: absolute;
left: -100vw;
}

We then set a color value for the heart depending on whether our checkbox is checked or not. We use a color picker to take the actual values out of the sprite.

[for='toggle-heart'] {
color: #aab8c2;
}

[id='toggle-heart']:checked + label {
color: #e2264d;
}

Center and enlarge

We also set cursor: pointer on the label and increase the font-size because it looks too small otherwise.

[for='toggle-heart'] {
font-size: 2em;
cursor: pointer;
}

Then we position it in the middle of the screen so we can see it better. Thank you, flexbox!

body {
display: flex;
justify-content: center; /* horizontal alignment */
margin: 0;
height: 100vh; /* the viewport height */
}

/* vertical alignment, needs the height of
the body to be equal to that of the
viewport if we want it in the middle */
[for='toggle-heart'] {
align-self: center;
}

We now have a heart that's grey when the checkbox isn't checked and crimson when it is:

Animating the size growth of the heart

Looking at the sprite, we see that the heart is scaled to 0 from frame 2 through frame 6. After frame 6, it starts to grow and then from a certain point it decreases a bit. This kind of grow is the perfect use case for the easeOutBack timing function. We take the start of grow to be 17.5% because that's a nice number that seems like a pretty good approximation given our total number of frames. Now we need to decide how to do this scaling. We cannot use a scale() transform because that would also affect any descendants or pseudos of our element and we don't want those scaled to 0 when our heart is. So we use font-size.

@keyframes heart { 0%, 17.5% { font-size: 0; } }

[id='toggle-heart']:checked + label {
will-change: font-size;
animation: heart 1s cubic-bezier(.17, .89, .32, 1.49);
}

The result of the above code can be seen in the following Pen:

If we don't include the 0% or 100% keyframes, they get automatically generated using the values we have set for that element (in our case font-size: 2em), or, if we haven't done that, from the default values (which would be 1em in the case of the font-size).

The bubble

Now let's move on to the pseudo elements that create the bubble (and also the particles, which we'll cover next). We set position: relative on our heart label so we can position them absolutely. We want them underneath the heart, so we use z-index: -1 to do this. We want them in the middle, so at 50% from the top and left it is. Both the bubble and the particles are round, so we give them border-radius: 50%. We're going to start using the SCSS syntax here, since we're going to end up using it as we need to do a few computations anyway.

[for='toggle-heart'] {
position: relative;

&:before, &:after {
position: absolute;
z-index: -1;
top: 50%; left: 50%;
content: '';
}
}

Looking at the sprite we see that, at its biggest, the bubble is a little more than twice the heart, so we take its diameter to be 4.5rem. We use rem units, not em because the font-size of the element is being animated to change the size of the heart. We size and position our ::before pseudo in the middle. We also give it a test background just to see it's there and it looks right (we remove this later):

\$bubble-d: 4.5rem; // bubble diameter
\$bubble-r: .5 * \$bubble-d; // bubble-radius

[for='toggle-heart']::before {
margin: -\$bubble-r;
width: \$bubble-d; height: \$bubble-d;
background: gold;
}

So far so good:

From frame 2 through frame 5, the bubble grows from nothing to its full size and goes from a crimson to a violet. Then, through frame 9, it grows a hole in the middle until this hole is as big as the bubble itself. The growing part looks like a job that animating a scale() transform can do. The growing hole we can get by animating the border-width from \$bubble-r (the bubble radius) to 0. Note that we also need to set box-sizing: border-box on the bubble (the ::before pseudo) for this to work.

[for='toggle-heart']:before {
box-sizing: border-box;
border: solid \$bubble-r #e2264d;
transform: scale(0);
}

@keyframes bubble {
15% {
border-color: #cc8ef5;
border-width: \$bubble-r;
transform: scale(1);
}
30%, 100% {
border-color: #cc8ef5;
border-width: 0;
transform: scale(1);
}
}

We can compact the keyframes with a mixin:

@mixin bubble(\$ext) {
border-color: #cc8ef5;
border-width: \$ext;
transform: scale(1);
}

@keyframes bubble {
15% { @include bubble(\$bubble-r); }
30%, 100% { @include bubble(0); }
}

We also make the pseudos inherit the heart animation, switch them both to an easeOutCubic kind of timing function and change the animation-name for each individually:

[id='toggle-heart']:checked + label {
&::before, &::after {
animation: inherit;
animation-timing-function: cubic-bezier(.21, .61, .35, 1);
}

&::before {
will-change: transform, border-color, border-width;
animation-name: bubble;
}

&::after { animation-name: particles; }
}

We can check out what the above code produces in the following Pen:

The particles

Looking at the sprite, we can see that we have seven groups of two round particles each and that these groups are distributed on a circle. Closeup of three consecutive frames in the sprite, showing the particles in groups around the heart.

What changes about them is their opacity, their position ( because the radius of the circle the groups are on increases) and their size. We create the particles with multiple box shadows (one for each particle) and then we animate the opacity of the pseudo and the offsets and the spread of these box shadows.

The first thing we do is to decide on a particle's dimensions, then size and position our ::after pseudo-element.

\$particle-d: 0.375rem;
\$particle-r: 0.5 * \$particle-d;

[for='toggle-heart']:after {
margin: -\$particle-r;
width: \$particle-d; height: \$particle-d;
}

We distribute the seven groups of particles on a circle. We have 360° on a circle, as illustrated by the following demo:

We split these 360° into as many parts as many groups we have. Each vertex of a polygon in the demo below would mark the position of a group.

We go clockwise and we start from the + of the x axis (3 o'clock). If we want to start from the - of the y axis (12 o'clock), then we need to subtract 90° from the angle corresponding to the position of each group.

Now let's see how we code a distribution of groups on a circle whose radius we initially take as big as the radius of the bubble (\$bubble-r), starting from the top (12 o'clock). If we consider we're to have just one particle in the middle of each such group, then our code should be:

\$n-groups: 7; // number of groups
\$group-base-angle: 360deg/\$n-groups;
\$group-distr-r: \$bubble-r; // circular distribution radius for groups

@for \$i from 0 to \$n-groups {
// current group angle, starting fron 12 o'clock
\$group-curr-angle: \$i*\$group-base-angle - 90deg;
// coords of the central point of current group of particles
\$xg: \$group-distr-r*cos(\$group-curr-angle);
\$yg: \$group-distr-r*sin(\$group-curr-angle);

}

Setting box-shadow: \$shadow-list on our ::after pseudo gives us the following result:

Now let's take the case where we have two particles in each group.

We position the particles in a group on a circle (with a radius of, let's say, equal to the diameter of our ::after pseudo - \$particle-d) around the central point of that group.

The next thing we need to think of is the start angle. In the case of the groups themselves, the start angle was -90° because we wanted to start from the top. For the individual particles, the start angle is the angle corresponding to the group (the one we use to compute its coordinates) plus an offset angle that's the same for all the particles around the heart. We take this angle to be 60° because that seems to look nice.

The code for computing the positions of all the particles and adding a box-shadow at each of those positions is below:

\$n-groups: 7;
\$group-base-angle: 360deg/\$n-groups;
\$group-distr-r: \$bubble-r;
\$n-particles: 2;
\$particle-base-angle: 360deg/\$n-particles;
\$particle-off-angle: 60deg; // offset angle from radius

@for \$i from 0 to \$n-groups {
\$group-curr-angle: \$i*\$group-base-angle - 90deg;
\$xg: \$group-distr-r*cos(\$group-curr-angle);
\$yg: \$group-distr-r*sin(\$group-curr-angle);

@for \$j from 0 to \$n-particles {
\$particle-curr-angle: \$group-curr-angle +
\$particle-off-angle + \$j*\$particle-base-angle;
// coordinates of curent particle
\$xs: \$xg + \$particle-d*cos(\$particle-curr-angle);
\$ys: \$yg + \$particle-d*sin(\$particle-curr-angle);

}
}

Now this results in what can be seen in the following Pen:

Rainbow particles

The positions look pretty good, but all these shadows use the color value we have set for the heart. We can make them rainbowy by giving each particle a hsl() value depending on the index of the group it is in (\$i) and on its index within that group (\$j). So we change the adding to the shadow list part:

hsl((\$i + \$j) * \$group-base-angle, 100%, 75%);

This simple change gives us rainbow particles:

We could even introduce some degree of randomness in picking the hue, but I felt pretty satisfied with this result.

When animating the particles, we want them to go from the position we have them in now, which means groups on the circle of radius \$bubble-r, a bit outwards, let's say, until groups are on a circle of radius 1.25 * \$bubble-r. This means that we need to change the \$group-distr-r variable.

At the same time, we want them to shrink from their current full size to zero. Shrinking box shadows without a blur to zero means giving them a negative spread radius whose absolute value is equal to at least half the smallest dimension of the element or pseudo they're set on. Both dimensions of our :after pseudo are equal to \$particle-d (the particle diameter), so our spread radius should be -\$particle-r (the particle radius).

To recap, in state 0, we have a group distribution circle of radius \$bubble-r and a spread radius of 0, while in state 1, we have a group distribution circle of radius 1.25 * \$bubble-r and a spread radius of -\$particle-r.

If we use a variable \$k for the state, then we have:

\$group-distr-r: (1 + \$k * 0.25) * \$bubble-r;
\$spread-r: -\$k * \$particle-r;

This leads us to creating a mixin, so we don't write those @for loops twice:

@mixin particles(\$k) {
\$n-groups: 7;
\$group-base-angle: 360deg / \$n-groups;
\$group-distr-r: (1 + \$k * 0.25)*\$bubble-r;
\$n-particles: 2;
\$particle-base-angle: 360deg / \$n-particles;
\$particle-off-angle: 60deg; // offset angle from radius
\$spread-r: -\$k * \$particle-r;

@for \$i from 0 to \$n-groups {
\$group-curr-angle: \$i * \$group-base-angle - 90deg;
\$xg: \$group-distr-r * cos(\$group-curr-angle);
\$yg: \$group-distr-r * sin(\$group-curr-angle);

@for \$j from 0 to \$n-particles {
\$particle-curr-angle: \$group-curr-angle +
\$particle-off-angle + \$j * \$particle-base-angle;
\$xs: \$xg + \$particle-d * cos(\$particle-curr-angle);
\$ys: \$yg + \$particle-d * sin(\$particle-curr-angle);

hsl((\$i + \$j) * \$group-base-angle, 100%, 75%);
}
}

}

Now let's look at the sprite a bit again for a moment. The particles don't appear until frame 7. 7 is a fourth (or 25%) of 28, which is pretty close to our actual number of frames (29). This means our basic animation of the particles would look something like this:

@keyframes particles {
0%, 20% { opacity: 0; }
25% {
opacity: 1;
@include particles(0);
}
}

[for='toggle-heart']:after { @include particles(1); }

This can be seen in action in the following Pen:

Tweaks

It looks fine in all browsers except Edge/IE, where the particles don't really shrink to nothing, they stay there, really tiny, barely visible, but still visible. A quick solution for this would be to increase the absolute value of the spread radius a tiny bit:

\$spread-r: -\$k * 1.1 * \$particle-r;

Another issue would be the fact that some operating systems convert the unicode heart to an emoji. I've found a solution that should prevent this from happening, but it looks ugly and it proved to be unreliable, so I ended up applying a filter of grayscale(1) when the checkbox isn't checked and removing it when it gets checked.

A couple more tweaks like setting a nice background and a font on the body and preventing heart selection and we get:

Accessibility

There's still a problem with this, an accessibility problem in this case: when using the keyboard for navigation, there's no visual clue on whether the heart toggle is focused or not (because we have moved the checkbox out of sight). The first solution that comes to mind is adding a text-shadow on the heart when the checkbox is focused. A white one seems like the best bet:

[id='toggle-heart']:focus + label {