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%;
border-radius: 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.
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:
$shadow-list: (); // init shadow list
$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);
// add to shadow list
$shadow-list: $shadow-list, $xg $yg;
}
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:
$shadow-list: ();
$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);
// add to shadow list
$shadow-list: $shadow-list, $xs $ys;
}
}
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:
$shadow-list: $shadow-list, $xs $ys
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) {
$shadow-list: ();
$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);
$shadow-list: $shadow-list, $xs $ys 0 $spread-r
hsl(($i + $j) * $group-base-angle, 100%, 75%);
}
}
box-shadow: $shadow-list;
}
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 {
text-shadow:
0 0 3px #fff,
0 1px 1px #fff, 0 -1px 1px #fff,
1px 0 1px #fff, -1px 0 1px #fff;
}
It didn’t look like it had enough contrast to the initial grey state of the heart so I ended up changing the grey from the sprite to a darker one.
Update: As David Storey suggested in the comments, I’ve also added aria-label='like'
to the label.
The final result
Two simple divs; thoroughly complex UI behavior. Wow! Thanks Ana for a very detailed and complex example of how we can use CSS to do things that appear to be quite complicated.
Really Amazing !!
Really well done, Just shows what you can create with little code :)
Very cool effect Ana, thanks for sharing! Not trying to take away from the article at all, but you actually used two elements. I saw the article title and was baffled how someone could pull it off with just one, no JS, no Images/SVG so I thought it’d be good to mention since it felt just a bit clickbait-ey. If input’s weren’t replaced elements and their pseudo’s could pick up on their hosts
:checked
state then it might be possible to do it with one element.As a potential optimization to this technique, you could wrap the label around the input which would eliminate the need for
for
andid
on their respective elements. It would mean a 3rd element, but would eliminate the potential id namespace nightmares from a huge Twitter feed like scenario.Again, thanks for writing this up, it was great!
I’ve always found it a bit weird wrapping labels around inputs, but alas it does seem fairly common practice.
I’m sure each tweet would have a unique ID of sorts, so it could easily just be appended like
abc123_like
. This would be the least of the issues.I’ve personally found the wrapping labels around inputs weird and wrong from a semantic point of view – inputs are not a part of labels.
It’s also preferable not to wrap labels around inputs for accessibility reasons (better screen reader support) and accessibility-related styling reasons (no JS required to make the whole thing accessible if you hide the checkbox/ radio button so that you can put your own nicely styled version).
And yes, every tweet has an unique ID – it’s the number the tweet URL ends in and, if you check with dev tools, you can find that number inserted into a number of IDs and data attributes. So IDs definitely wouldn’t be a problem in the case of something like twitter.
Ahh! This is so clever, I absolutely love it. Thanks for sharing your process with us Ana, there’s lots to take inspiration from here. Nice work!
Amazing!!
Wow! Ana, you rock! Impressive!
Amazing what you can do today with SASS and CSS…thanks for sharing your master knowledge of these languages…I will also say very impressive…
Wow—that is really impressive. Will use this example for some SASS ideas for sure. Great work!
Amazing Ana! Indistinguishable from the original one!
Dude, props on using shadows for this. Shadows are awesome. I used box shadows to generate my avatar (yup, also a single element).
That’s really cool, have you seen the CSS fractal demos Gregor Adams has been putting out? http://codepen.io/collection/tvJqF/
Oh wow, no, I hadn’t. I love the 3D fractal tree. The DOM is absolutely perfect for a fractal like that – it basically becomes a proper scene graph.
How would you prevent iOS / Mac OS from substituting the Unicode heart with an emoji character?
Good question! No clue, I probably wouldn’t. I’d probably just apply a
grayscale()
filter when the checkbox isn’t checked and maybe also tweak the brightness and saturation a bit. Maybe also play withhue-rotate()
for the checked state if really necessary.Note that the label should have an aria-label attribute for screen reader accessibility. Otherwise the screen reader will read something like “Heavy black heart, checkbox, unchecked”. Presumably in this case it should be
aria-label="like"
as that is what the native app uses.They also use a button, but I don’t think you could do that without JS as even if you put role=”button” on the checkbox or used a button, you’d still need to add the aria-pressed state via JS.
Updated! :)
thanks
Hey Ana,
Seems like you actually read my post in December and took a few ideas from it :-) : http://medium.com/@OxyDesign/twitter-s-heart-animation-in-full-css-b1c00ca5b774
Same challenge to replicate it with only CSS/Html but a few differences though :
Mine has more html elements than yours but it’s mainly for the Heart. I used 4 elements to create a shape really close to the original one. That was a bit tricky to do but I didn’t want to use the unicode solution because I wanted to make sure the Heart wouldn’t be altered depending on which device/OS/browser/fonts you use (when I look at your heart with my Chrome/OSX, it doesn’t have the same shape as in your gif for example. Probably because of the font).
About the Particles, we used the same concept (multiple box-shadows), but I also animated them because in each couple of Particles when the Circle is fading they are moving to the outside, at a different speed and one Particle grows when the other shrinks, and their colors also change.
About the no JS solution, I like the idea of the famous checkbox trick (I used it in many projects), but in this case sooner or later you’ll need to add some JS to sync the like/unlike with the server so I added one line of JS just to toggle the class to trigger the animation (everything else in the JS in my Codepen is for debug and demo purpose) and actually Twitter uses a button element with a JS behavior (for both triggering the animation and syncing with the server), no checkbox or form.
As I never had an answer from you (https://twitter.com/OxyDesign/status/674295377928024064), I assume you liked it!
The mixin of Particles is so cool!
I remember I forked this pen last year, and just re-styled the sharp of heart & the style of ring.
(I used
box-shadow
to replaceborder-width
for performance)@Nicolas: I hadn’t seen it. :( I actually did a search before writing this to see if it had been done before or if there’s still a point in doing the article and it didn’t come up, only found articles explaining the sprite technique.
The heart definitely varies with the browser/ OS, even though I was careful to explicitly set a font. It’s so different from anything else in IE!
But I felt this was the quickest way, plus I’m normally one for avoiding anything that requires any aesthetic sense at all. That’s something I completely lack, I’m 100% technical and 0% artistic, I know I’m bound to mess things up if I go there. So I just don’t go there. Same thing about the animation. I just wanted something quick and easily computable. I know my animations lack finesse, I’ve been told that before, but I’m pretty happy with how this one came out given my lack of artistic sense and how little time I spent on it.
Completely agree about the use of JS in the case of something like twitter. I wouldn’t have even considered not using JS if I had actually done it for twitter and not just as play.
@Rplus: That’s very interesting. I started out using
box-shadow
because it felt like a better idea to me, but I wasn’t satisfied with the animation performance, so I switched toborder-width
. Not happy with the animation performance in this case either, but still a bit better than thebox-shadow
one.@Rplus : Thanks ! This function took me a bit of time to write and to figure out the exact behavior of particles. Glad you liked it !
About the heart, I agree yours looks better and at first I tried to simply create a heart and it looked better than my current one. But then I tried to make it look like the real one as much as possible. And the real one has a weird shape :-)
Nice for the ring, it looks like mine but if it’s more performant then great ! (on my current computer I can’t spot the difference though but I believe you)
@Ana : No worries !
I do this kind of challenges to keep myself up to date with CSS as I’m mainly working with Javascript these last months and years and I don’t want to lose my habits and knowledge in this area. But I wasn’t looking for any kind of fame (I made many small Codepens and other tech posts on medium that haven’t reached more than a few hundreds of views) and I was the first surprised by the number of views I had for it in the first weeks (currently almost 20k views / 350 likes, which for me is just huge). And this also led me to my new role which is an amazing professional opportunity for me, and wasn’t expected either.
I understand your point about the artistic side of it, it’s just me, I can’t go to sleep if it’s not as close as possible to what I want or have in mind and I can spend hours to fix an exact size, exact mouvement etc… (and that one actually took some of my nights). Maybe it is because I’m a former designer, could be an explanation :-)
And … you mainly use IE ?!?!
I don’t mainly use IE. I just tested the heart in every browser I could, IE included.
You don’t have to brag (-: Very clever stuff
this is great article but this is little much for person who just stating to design websites even i spend 3 years in this field some sections i didn’t get that
Amazing!! Article :)
That’s really amazing!
I did a fork. It uses much of the SCSS, and the concept of the HTML, but without ID’s so you can repeat wherever you like. The concept is quite nice, I would prefer the use of the HTML entity for the heart, I’ve messed around with the dimensions to get it to stop making my screen blink when I have many hearts on a page. I’ve also made the outline configurable because I didn’t like it, but didn’t want to remove it. It’s all personal preference rather than anything wrong with the original, which I imagine took considerable time and effort.
As far as the ID thing is concerned, see my comment above – twitter already has a solid ID system in place and not putting the input inside the label means better support from assistive technologies.
Also, why use HTML entities? From what I’ve found, at this point you shouldn’t run into trouble anymore if you use the Unicode heart. And the meaning of a unicode heart is clear right away for anyone, while I certainly couldn’t tell what
❤
might create on the screen without knowing beforehand or without doing a search.Mind. Blown. :D
Great article!
I keep thinking of moving from back-end to front-end dev, but this stuff astounds me – not sure I’d think of the amazing combination of techniques.
I want to know how you got the original sprite.
This is just amazing how you can do it with just 2 divs and a bunch of CSS. Mass respect!
WOW, Impressive.
A brilliant use of box-shadows and complex UI technique out of 2 simple DOM elements.
Loved it.
Reader christopher hayes wrote in: