Create loading animation with canvas
Recently I’ve been working with category pages for one e-commerce site. A category page shows a list of products available for sale where each product is represented by one or more images. If there is more than two images for a product, it’s a common practice to group them into a slider.
As you can see, there may be a lot of information associated with a product: a title, a list of sizes, a quick shop button, a wishlist button, promo labels, available colors, etc. All that data is relevant to a product image, except the list of colors. Only one color label on the list corresponds to the product that you see on the picture. Interesting, right? I hope you are as curious as I am and you wonder what happens when you pick another color from the list. So pick one and prepare yourself for one of the following outcomes:
- nothing happens. This is the most frustrating experience, I should say. Obviously this shop does not want to make our life a bit easier and spare us from two extra clicks to see product images of the desired color.
- a redirect to a product page of a selected color. That is much better than the previous result, but still far from being perfect as we should find our way back to the products list if we don’t like this particular product. With dozens of tabs already opened that may be not a trivial task.
- product images are being updated. This is the most desirable outcome as we stay on the same page and see how new product images appear to fit the color of our choice. Also it brings a sense of interactivity. We can play with colors, try several options we like and immediately see how it looks on a model.
The last outcome is the best one, but it often has a small imperfection. There is a time lag between the click and the moment we see new images. It happens because we have to load new images. In case of a slider we load more than two images at once. Fortunately that does not take a lot of time, but nonetheless it’s enough to make it noticeable.
The prime victims here are mobile devices, as they have displays with a very high pixel density which means larger images to load. Believe it or not, but an optimized image for mobile devices may be twice as large and heavy as the image for desktop monitors. We should also take into account unstable mobile connection, thus milliseconds of impatient waiting can easily stretch into unbearable SECONDS which may make you think that something went wrong and the web site is not responding.
Not a surprise that smart web developers know how to handle such situations. Like true magicians, they make us focus on something else, something eye pleasing and constantly moving :)
This cute little fella keeps the world from falling apart. Once we see it, everything becomes normal: stress level, agitation level, our confidence in web applications and hope in the bright future for humanity as well.
But in spite of all these positive effects there is one thing that bothers me a little. I find this solution way too generic. The loader always stays the same no matter what content is being loaded, plus many sites use it without any conceptual changes. That makes me think that I can do something about it. So let’s return to our list of colors and try to create a unique loading animation for each product color!
Classic loading flow
Here is how an image loading process looks like if we break it into functional steps and show them in a chronological order:
- Initiate image loading. That may be any action of the user or an event. At this point we prepare the data to fetch new images.
- Overlay current image. We use a loading animation to hide the existing image to be able to replace it by a new one later.
- Load new images. Or we can call it a preload phase, because we load new images but do not add them to the DOM tree. Not yet. For a moment, all new images are being stored in a browser’s cache.
- Replace images. Remove old images from the DOM and add new ones. If the number of images does not change, we can simply update image links for existing
<img/>
tags. - Remove overlay. Remove the container with the animation from the DOM or make it transparent.
Canvas animation flow
To make a loader less generic I decided to divide loading animation into two phases. During the first phase our loader covers the initial image. The second phase begins when a new image is loaded and we gracefully unveil it.
The next step is to define animation objects. The word animation suggests that we animate something, right? I wanted to find something simple to work with. Basic geometric figures, like squares, lines, triangles, etc. nicely fit that requirement. After giving a closer look at all the figures I picked a circle, because it requires only two parameters to create it: coordinates of the center and the radius. So circles will be base elements in my animation.
Let’s draw a sketch to have some visual reference and see how the animation matches main functional steps of the loading process. Before we go any further, please note that the animation takes place inside a canvas container with a transparent background. A canvas has the same size as our target image and is positioned on top of it.
<div>
<img width="350" data-index="1" src="target_image.jpg"/>
<canvas></canvas>
</div>
On the left we can see the first functional step “Initiate image loading”. Inside a canvas object we create a number of circles of different sizes and place them beyond a visible image area around a border line. At this point user does not see anything but the initial image. When “loading” event is fired, each circle starts to move toward a destination point marked by an arrow.
As soon as each circle reaches its destination point, we can say that we are at the second step “Overlay current image” and ready to load a new image.
You may have noticed that the image on the right is not fully covered by circles. There are some areas where we can clearly see the image. Which is kind of not right. That’s a good observation! I’ll return to this aberration later and explain how I’ve solved this in the chapter with technical highlights and solutions.
The second phase is a negative mirror of the first one. When a new image is loaded (step #3) and added to the DOM (step #4), we start to play animation backwards. This time, each circle has a new destination point outside a visible area. With the last circle disappeared beyond image borders the loading process is over and we are ready to do it again.
In the beginning I said that each animation would be unique for each product color. Actually it will be unique for each product image and here is why:
- dynamically generated elements. All animation elements are being created at runtime for each loading event using random values for main animation parameters: circle sizes, initial positions and destination points. Thus it’s very unlikely that we’ll get two identical animations.
- unique color pattern. The circle’s destination point on the image surface defines its color. This simple effect gives some sort of bond between the animation and the image, creating a more gentle and smooth transition.
Technical highlights
The last thing I want to do is to take up your time, thus I won’t comment the entire code line by line. Instead, I’ll pick the most interesting parts from my point of view and explain some problems I had to solve to get the animation running. Of course, if you want to see all the code that badly, help yourself, the link to a github repository and the link to a live demo are at the end of this article.
Why canvas?
Wouldn’t it be easier to use CSS styles and DIVs? The animation may look very basic, even simple and sure such question may cross your mind. There is one thing that you can’t do with a classic CSS animation: you can’t get colors from the image at runtime. With canvas you can do it with ease, plus it lets you work with animated objects in a more efficient way, preventing the DOM from pollution with extra DIVs for example.
// COLOR EXTRACTION SNIPPETconst img = document.querySelector('.slide img');
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0, img.width, img.height);
// Get a color of a given img pixel
let rgba = canvas.getContext('2d').getImageData(circleProps.x, circleProps.y, 1, 1).data;
API considerations
To create any animation we should update or redraw an image at regular time intervals. In movies the update rate is about 24 times per second, in video games it goes up to 60 times per second or even higher. That leaves us with 1s / 60 ~ 17 ms for all the calculations per frame. In real life we should be even faster, because JavaScript runs in a single thread and we have to leave some space for other code to be executed, like event handlers or other API calls. That means that we should make a choice. If the animation is rather simple, like the one I’ve described above, then canvas 2D API is a good choice. But with more advanced graphics, it’s better to render it with WebGL that performs all calculations on GPU, which is much faster. As always, this speed comes with a price: the learning curve is very steep, thus it’s almost impossible to create something quickly if you are not already familiar with a 3D production pipeline. So I used 2D API with a window.requestAnimationFrame()
function that tries to execute the code once in 17ms.
Animated objects
All visible elements in the animation are circles. Each circle is an instance of an object named “Circle”. When I create a circle, I define its color, radius, position and a trajectory which is a separate object by itself and defines all aspects of circle’s movement. That is probably not the best solution, but I find it useful as it keeps movement logic separated from individual circle properties. It also allows me to define an individual movement pattern for a given circle.
Movement
When I worked with CSS animations, I never had to calculate all the intermediate positions between two points on the screen. It was sufficient to set initial coordinates, final coordinates and duration, and a browser would take care of everything else.
Another typical feature of CSS animations is the direction of movement. In most cases objects move either horizontally or vertically due to a rectangular nature of layout grids. Other directions are practically not used at all.
Working with canvas is a totally different story. I had to learn how to move objects all over again. With help of basic algebra I came up with these two equations to calculate next object’s position in 2D plane.
nextX = currentX + step * direction * Math.cos(angle);
nextY = currentY + step * direction * Math.sin(angle);
To move a circle we should know three things: its initial position (A), its final position (B) and how much time we have in our disposal to move it between them (T). With these initial values we can calculate other missing parts of our equations.
First of all we need to know the total length of the trajectory (AB):
// pseudo code
dx = B.x - A.x;
dy = B.y - A.y;
AB = Math.sqrt( dx * dx + dy * dy );
Then we can calculate the length of the incremental step (ab) that we should take each time to get closer to our destination point on each render cycle. In ideal conditions, when the frame rate is constant -let’s say it’s 60 fps- and the movement is linear, it is simple:
ab = AB / (fps * T)
In practice FPS value may be unstable, due to different amount of calculations in each render cycle and unknown CPU capacity of the host device. In that case an object may not reach the destination point by the end of the animation time. There is a simple hack to partly solve this problem. Apply FPS throttling, i.e. artificially reduce maximum FPS rate down to 30 fps. Thus it’s less likely to have drawdowns in FPS, though they are still probable. I chose another way to mitigate this issue. I’m using an easing function to calculate the length (ab):
// pseudo code
t - elapsed time in ms
b - init length
c - trajectory length (AB)
d - animation duration (T) in ms
ab = -c / 2 * (cos(PI * t / d) - 1) + b
The easing function is a function of time. No more FPS dependency! It returns the step length (ab) value for a given moment in time. Also, in this particular case, a step length value is not constant, it changes over time, which gives us a nice visual effect when a circle smoothly increases its speed in the beginning and then slows down around the destination point.
The last piece of the puzzle is the angle. In JavaScript, sinus and cosinus functions accept the angle in radians. Please be aware of this obstacle and don’t make silly mistakes as I did by passing values in degrees. There is an elegant way to find the angle for any arbitrary vector on the 2D plane. Move XOY point to the end of your vector (point b) and then use vector’s start coordinates to calculate the angle.
// pseudo code
angle = Math.atan2(a.y - b.y, a.x - b.x)
Math.atan2()
returns the clockwise angle (aOX), measured in radians, between vector’s start point (a) and the positive x-axis.
If you create non linear movement, then you should calculate the angle for each ab vector, but if you have a linear movement as I do, then you can calculate the angle once for the entire AB trajectory.
Image overlay
Let’s return to the moment when circles cover the image. If the destination point and radius of each circle are truly random, then we won’t be able to cover 100% of the image surface in a predictable and consistent way. In most cases some parts of the image will stay visible. It turns out that to make it work right our random values should be less random. We have to apply some constraints to make our random values behave in a more predictable manner.
Basically, what do we want to do here? We want to cover a rectangular object with round ones. Let’s consider the situation where we have only one rectangle and one circle. In this case the solution is obvious, the radius of the circle should be equal or greater than a half of the rectangle’s diagonal.
Believe it or not but we’ve almost solved the problem. Let’s make one step further and apply the same approach for the case with one rectangle and multiple circles. All we need to do is to divide the initial rectangle into several smaller ones. Each small rectangle will define the minimal radius (r) and the final position (B) for one circle.
That’s it. 100% of the image surface is covered with circles.
Demo
If embedded widget does not work for you, please use one of the following links:
- a direct link to jsfiddle (live demo)
- source code at GitHub
What’s next?
It was so much fun to work on this small side project. It helped me realize that for all these years that I have been working with JavaScript I didn’t really consider canvas as a potentially huge thing for e-commerce sites. It’s time to fix this and pay more attention to canvas :)
I don’t want to promise anything, but If I were you, I’d definitely try to use Rust in combination with canvas. There is a library, called OpenCV, that allows to do a lot of interesting things with images. Theoretically we can use it in a browser with the help of Rust and Webassembly. That opens up a door to the wonderland where everything that we were dreaming about is possible. For instance, we could extract the background of a given product image and replace it with some animation on the client side at runtime.
To be realistic, the next thing that is worth trying as a JS developer is to figure out how to use Webassembly and create something simple with Rust. Sounds weird but IMHO Rust is a good match for JS. By the way, if by any chance you have already worked with Rust and did something for e-commerce sites, could you share your experience in the comments: what did you do and was Rust a good match for your task?
Thank you for your time and patience for staying with me that long. Have a good time and start learning Rust ;)