Motion blur

Create a smooth canvas animation

Create a smooth canvas animation with JavaScript. Learn how to clear the canvas, add motion trails and easing, and keep it sharp on every screen. By the end of this tutorial, you'll be able to create a basic animation with JavaScript.

CSS animation, Web Animations API or canvas?

In this tutorial, you're going to create an animation on the HTML5 canvas. But there's more than one way to make things move on a web page. Which one you pick depends on what you want to animate. Here's a quick overview:

  • CSS animations and transitions - The easiest way to animate HTML elements, like a button that grows when you hover over it or a menu that slides in. You describe the start and the end in a CSS animation, and the browser does the rest.
  • Web Animations API - The same kind of animations as CSS, but started from JavaScript with element.animate(). The Web Animations API lets you pause, reverse or speed up an animation while it runs.
  • Canvas - You draw every frame yourself, with JavaScript. It's more work, but you can draw anything you like, and hundreds of moving objects are no problem. That's why games use it.

So, for a website with a few moving elements, CSS is usually the way to go. For a game, a particle effect or anything else with lots of moving objects, you'll want the canvas. That's what this tutorial is about.

In the past, people used Flash to make animations for the web. Wondering what happened to it? You can read all about it in the introduction to HTML5 game development. Now, let's continue and learn how to create your first canvas animation.

Draw objects dynamically on the canvas

By the end of the previous tutorial on the game loop, you already had a rectangle moving across the screen. Now it's time to take a closer look at how that works, and how far you can take it. Let's start with the most basic version, so you can see what each part does. Here's a simple draw() function:

function draw() {
    context.fillStyle = '#ff8080';
    context.fillRect(rectX, rectY, 150, 100);
}

context is the object you draw with. You get it from your canvas with canvas.getContext('2d'), as explained in the canvas setup tutorial. fillRect() draws a rectangle at an x and y position, with a width and a height. Here, that's 150 by 100 pixels. The position comes from two variables, rectX and rectY. Keep changing their values, and the rectangle moves.

Let's combine this with a basic game loop, a function that runs once for every frame. With the following code, you can update the position of the rectangle:

let rectX = 0;
let rectY = 0;

function gameLoop(timeStamp) {
    // Update game objects in the loop
    update();
    draw();

    window.requestAnimationFrame(gameLoop);
}

function update() {
    rectX += 1;
    rectY += 1;
}

When should you update the position of your game objects?

A simple update() function slightly changes the coordinates of the rectangle. Every frame, gameLoop() calls the update() function, and this makes the rectangle move. At the end, window.requestAnimationFrame() asks the browser to run gameLoop() again for the next frame. It's important to call update() before draw(), so that you'll always draw the latest state of your game to the canvas.

Remember this when you start to add other tasks to your game loop. Always update the state of your game objects first, and draw everything to the screen last. For now, your game loop looks like this:

A blur of repeated graphics

When you run the code, you'd expect to see the rectangle move in a diagonal line, from the upper-left corner to the lower right. Here is what you get:

What is happening here? It looks like an endless repetition of rectangles, creating a big blur. The rectangle is drawn on the canvas, but the previous rectangle is never removed. Why is that?

How does the canvas work?

Well, the canvas works like a whiteboard. You can draw on it, change the position of the rectangle, and draw it again. Do this quickly enough and you'll get the illusion of motion.

But if you forget to clear the canvas, each new drawing ends up on top of the previous ones, creating a blur of drawings, like in the example above.

You can make use of this effect to create some interesting graphics, but that's not what you need right now. You want to animate the rectangle, not create a blur.

Clear the canvas before drawing

To solve this problem, you'll have to clear the canvas before you draw each new frame. So, every time you draw, you start with a clean slate. This will prevent the blur effect.

Add clearRect() to the draw() function to always clear the canvas before drawing:

function draw() {
    // Clear the entire canvas
    context.clearRect(0, 0, canvas.width, canvas.height);

    context.fillStyle = '#ff8080';
    context.fillRect(rectX, rectY, 150, 100);
}

The clearRect() function clears part of the canvas. In this case, it clears an area that covers the entire canvas. It starts at (0, 0), the upper-left corner, and covers canvas.width by canvas.height.

With clearRect() at the start of draw(), your game loop now has three steps: update, clear and draw.

When you run the new code, the rectangle is animated correctly. See the example below:

Move objects based on time

Recap - The JavaScript game loop tutorial covers moving objects based on time in much more detail, with a demo you can try at different frame rates. Already know how it works? Then skip ahead to the next section.

Right now, the update() function moves the rectangle a fixed distance every frame. That works fine until the frame rate changes. requestAnimationFrame() runs at the refresh rate of the screen, so on a 120 Hz screen the rectangle moves twice as fast as on a 60 Hz screen. And when a busy device skips a few frames, the rectangle slows down.

The fix is to move your objects based on time instead of frames. Here's the short version:

let oldTimeStamp = 0;
let movingSpeed = 120;

function gameLoop(timeStamp) {
    // Calculate how much time has passed, capped at a maximum
    let secondsPassed = (timeStamp - oldTimeStamp) / 1000;
    oldTimeStamp = timeStamp;
    secondsPassed = Math.min(secondsPassed, 0.1);

    update(secondsPassed);
    draw();

    window.requestAnimationFrame(gameLoop);
}

function update(secondsPassed) {
    // Move 120 pixels per second, no matter the frame rate
    rectX += movingSpeed * secondsPassed;
    rectY += movingSpeed * secondsPassed;
}

requestAnimationFrame() gives gameLoop() the current time in milliseconds, as timeStamp. Subtract the time of the previous frame and divide by 1000, and you get secondsPassed: the time since the last frame in seconds, often called delta time. At 60 fps that's about 0.0167 seconds, so the rectangle moves 2 pixels per frame. At 120 fps it moves 1 pixel per frame, but twice as often. Either way, it covers 120 pixels per second.

Math.min() caps secondsPassed at 0.1 seconds. When you switch to another tab, the browser pauses the game loop. When you come back, the first frame suddenly gets all the time you were away. Without a limit, your objects would jump across the screen in a single frame.

For an animation, delta time with a cap is all you need. Once you add physics, a timestep that changes from frame to frame can cause trouble, like a fast ball that passes straight through a wall. To prevent that, use a fixed timestep, as explained in the game loop tutorial.

Turn the blur into motion trails

Remember the blur of rectangles at the start of this tutorial? It was a mistake back then, but you can also use it on purpose. A ball that leaves a trail looks fast, a comet needs a tail, and a mouse cursor with a trail is just fun to play with.

The trick is to not clear the canvas completely. Instead, you paint over it with a see-through color. The old drawings don't disappear right away. They fade out a little more every frame:

function draw() {
    // Paint over the canvas with a see-through white
    context.fillStyle = 'rgba(255, 255, 255, 0.2)';
    context.fillRect(0, 0, canvas.width, canvas.height);

    context.fillStyle = '#ff8080';
    context.fillRect(rectX, rectY, 150, 100);
}

The last number in rgba() is the alpha: how solid the color is. With 0.2, every frame keeps 80% of the old picture. After 10 frames, only about 11% of a drawing is left, so at 60 fps the trail is roughly a sixth of a second long. A lower number gives a longer trail.

There's one catch. On a 120 Hz screen you paint over the canvas twice as often, so the trail is only half as long. You already know the fix: use time. Pass secondsPassed to draw() as well, and use it to calculate how much to paint over:

// Paint over 20% of the old picture for every 1/60 of a second
let fade = 1 - Math.pow(0.8, secondsPassed * 60);
context.fillStyle = 'rgba(255, 255, 255, ' + fade + ')';

Math.pow(0.8, secondsPassed * 60) is how much of the old picture is left after this frame, and fade is the rest. At 60 fps, fade is 0.2, just like before. At 120 fps, it's about 0.11. You paint over twice as often, but only about half as much each time, so you get the same trail. Try it out with the buttons below:

Trail

With a long trail, you might see a faint gray shadow where the balls in the demo above have been. That's a rounding problem. The colors on the canvas are stored as whole numbers from 0 to 255. Every frame, each pixel takes a small step toward white. Near white, that step is smaller than 0.5, so it rounds to zero, and the pixel gets stuck just below white. On a 120 Hz screen, the steps are even smaller, so the shadow is easier to see.

You can fix this by adding that last bit of white yourself. With the composite operation 'lighter', the canvas adds the color you draw to the color that's already there. Fill the canvas with rgb(1, 1, 1), and every pixel gets 1 closer to white:

// Push the last bit of the trail to white
context.globalCompositeOperation = 'lighter';
context.fillStyle = 'rgb(1, 1, 1)';
context.fillRect(0, 0, canvas.width, canvas.height);
context.globalCompositeOperation = 'source-over';

Do this right after painting over the canvas. Then switch back to 'source-over', the normal mode, where every new drawing covers what's underneath. A new canvas is transparent, not white, and on transparent pixels 'lighter' gives the wrong colors. So fill the canvas with white once before the animation starts. The demo above does all of this.

Add easing to your animation

Now that your animation is based on time, you can do some fun things with it. Right now, the rectangle moves at the same speed all the time. Things in real life don't move like that. A car speeds up gradually and brakes hard for a red light. You can give your animation that same feel by adding easing.

First, turn the time into a progress value from 0 to 1, where 0 is the start of the animation and 1 is the end. Then use that number to calculate the position:

const duration = 1.5;
let timePassed = 0;

function update(secondsPassed) {
    timePassed += secondsPassed;

    // Progress from 0 (start) to 1 (end)
    let t = Math.min(timePassed / duration, 1);

    rectX = lerp(50, 550, easeOutCubic(t));
}

// Go from a to b, with t from 0 to 1
function lerp(a, b, t) {
    return a + (b - a) * t;
}

function easeOutCubic(t) {
    return 1 - Math.pow(1 - t, 3);
}

The animation takes 1.5 seconds. Math.min() makes sure the progress stops at 1, so the rectangle stays at the end.

lerp() is short for linear interpolation. It finds the value that's a fraction t of the way from a to b. At the halfway point, lerp(50, 550, 0.5) gives 300. You'll find this little function in just about every game engine.

The easing function is where it gets interesting. It takes the progress and bends it. It still starts at 0 and ends at 1, but in between it goes faster or slower. easeOutCubic() starts fast and slows down at the end: halfway through the animation, the rectangle has already covered 87.5% of the distance.

Here is the result. The numbers in the corner show the progress and what the easing function turns it into:

Ease in, ease out or both?

There are three basic kinds of easing, plus linear motion without any easing:

  • Linear - The same speed from start to end. It looks a bit mechanical, but it's fine for a conveyor belt or a spinning wheel.
  • Ease in - Starts slow and speeds up. Use it for something that falls or leaves the screen.
  • Ease out - Starts fast and slows down. This feels natural for things that arrive or come to a stop, like a menu that slides in. When in doubt, pick this one.
  • Ease in-out - Slow at the start and at the end. Good for moving from one spot on the screen to another, like a camera that pans.

Here are the cubic versions of all three:

function easeInCubic(t) {
    return t * t * t;
}

function easeOutCubic(t) {
    return 1 - Math.pow(1 - t, 3);
}

function easeInOutCubic(t) {
    return t < 0.5
        ? 4 * t * t * t
        : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

Cubic means the progress is raised to the power of 3. With a power of 2 (quad), the effect is softer. With 4 (quart) or 5 (quint), it gets stronger. Switch between the easing functions in the demo below and keep an eye on the graph: it shows the position of the red block over time.

Easing

The demo has two blocks: a gray one that always moves linearly, for comparison, and a red one that uses the easing function you picked. With ease-out, the red block runs ahead of the gray one. With ease-in, it lags behind. But they always arrive at exactly the same moment.

Easing functions with four arguments

In older code, you'll often come across easing functions with four arguments instead of one. They come from Robert Penner, who wrote the classic set of easing equations back in the days of Flash. Here's the same ease-out in that form:

// Move from 50 to 550 in 1.5 seconds
rectX = easeOutCubic(timePassed, 50, 500, 1.5);

function easeOutCubic (t, b, c, d) {
    return c * ((t = t / d - 1) * t * t + 1) + b;
}

The four arguments are the time that has passed (t), the beginning value (b), the change in value (c) and the duration (d). To go from 50 to 550, the change is 500. Both versions draw the same curve and have the same name, so use only one of them in your code. The classic functions just do the lerp() for you. Need one that takes a progress value from 0 to 1? Pass 0, 1 and 1: easeOutCubic(t, 0, 1, 1). And watch out: these functions don't stop at the end. When t gets bigger than d, they just keep on going.

Our animation tool for easing functions has both versions. Click the Classic (t, b, c, d) tab above its code to get the classic versions.

The easing functions might look difficult to understand, but the good news is you don't really have to. As long as you know what goes in and what comes out, you can use them. Want to see more? The tool for easing functions shows all 31 of them in action, from a soft sine to a bouncing ball.

Easing functions
For JavaScript and CSS

Find the right easing for your animation or game. Use the tool to see it in action and make your project come to life.

Use the animation tool to find the easing that fits your animation. Play around with the update() function to create new movement effects. Next, you'll use easing for more than just the position.

Animate size, rotation, opacity and color

So far, you've only animated the position. But easing works for anything you can express as a number. Size, rotation, opacity, even color. Use lerp() in combination with an easing function again:

function update(secondsPassed) {
    timePassed += secondsPassed;
    let t = Math.min(timePassed / duration, 1);
    let eased = easeOutCubic(t);
    let colorEased = easeInOutCubic(t);

    size = lerp(0, 80, eased);        // Grow
    angle = lerp(0, Math.PI, eased);  // Half a turn
    opacity = lerp(0, 1, eased);      // Fade in

    // From red (255, 128, 128) to blue (0, 153, 176)
    red = Math.round(lerp(255, 0, colorEased));
    green = Math.round(lerp(128, 153, colorEased));
    blue = Math.round(lerp(128, 176, colorEased));
}

This time, you draw a square at (375, 200), the middle of a canvas 750 pixels wide and 400 pixels high. That takes a few new steps:

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height);

    context.save();
    context.globalAlpha = opacity;

    // Move to the center of the square and rotate around that point
    context.translate(375, 200);
    context.rotate(angle);

    // Draw around (0, 0), so the square grows from its center
    context.fillStyle = 'rgb(' + red + ', ' + green + ', ' + blue + ')';
    context.fillRect(-size / 2, -size / 2, size, size);
    context.restore();
}

Here's how draw() handles the size, rotation, opacity and color:

  • Size - Because the square is drawn around (0, 0), it grows from its center instead of from its top-left corner.
  • Rotation - translate() moves the origin of the canvas to the center of the square, and rotate() turns it around that point. The tutorial about images and sprites explains rotating in more detail.
  • Opacity - globalAlpha makes everything you draw see-through, from 0 (invisible) to 1 (solid).
  • Color - A color is three numbers: red, green and blue. Lerp each of them and the color changes smoothly. This one uses easeInOutCubic(), so the color doesn't rush to the end.

save() and restore() put the context back afterwards, so the next thing you draw isn't rotated or see-through.

For something that appears, like a coin or a button, try an easing that shoots past the end and comes back. With easeOutBack(), the size goes about 10% too far and then settles. That makes the object pop:

function easeOutBack(t) {
    // How far it goes past the end, about 10%
    const s = 1.70158;
    return 1 + (s + 1) * Math.pow(t - 1, 3) + s * Math.pow(t - 1, 2);
}

size = lerp(0, 80, easeOutBack(t));  // Grow and pop

Here's the result, with each property on a square of its own. The size uses easeOutBack(), so the first square grows a little past 80 pixels before it settles:

Make things float, pulse and orbit with Math.sin()

Not every animation has a start and an end. A coin that floats up and down, a heart that pulses, a moon that circles a planet: they just keep on going. For that, you can use Math.sin().

Math.sin() turns a number into a smooth wave between -1 and 1. Feed it the time, and the wave repeats forever. Multiply the result by how far you want to move, and add it to the starting point:

const period = 2;  // One full wave every 2 seconds

function update(secondsPassed) {
    timePassed += secondsPassed;
    let wave = Math.sin(timePassed / period * 2 * Math.PI);

    // Float 15 pixels up and down
    coinY = 150 + wave * 15;
}

Math.sin() goes through one full wave in 2 * Math.PI. Divide the time by the period, multiply it by 2 * Math.PI, and you get one wave every 2 seconds. It's based on time, so it's the same speed on every screen.

The same wave works for other things too. Use it for the size, and a heart starts to pulse. Use Math.cos() for x and Math.sin() for y, and an object goes around in a circle:

// Pulse between 90% and 110% of the size
let scale = 1 + wave * 0.1;
let size = 80 * scale;

// Go around in a circle with a radius of 70
let angle = timePassed / period * 2 * Math.PI;
let moonX = 375 + Math.cos(angle) * 70;
let moonY = 150 + Math.sin(angle) * 70;

Remember Pac-Man from the canvas drawing tutorial? Give his mouth a faster wave of its own, and he starts to chomp. (bite + 1) / 2 turns the wave into a number from 0 to 1, so the mouth goes from closed to open:

// One bite every 0.4 seconds
let bite = Math.sin(timePassed / 0.4 * 2 * Math.PI);

// From 0 (closed) to 0.2 * Math.PI (open, like in the drawing tutorial)
let mouth = (bite + 1) / 2 * 0.2 * Math.PI;

context.beginPath();
context.arc(pacManX, 150, 40, mouth, 2 * Math.PI - mouth);
context.lineTo(pacManX, 150);
context.fill();

arc() draws the circle from the angle mouth all the way around to 2 * Math.PI - mouth, so it leaves a gap on the right. lineTo() then adds a point in the center, and fill() turns the gap into a mouth.

Have more than one object? Give each one a head start of a fraction of a second. Instead of moving in sync, they move in a wave:

for (let i = 0; i < dots.length; i++) {
    // Each dot is 0.2 seconds ahead of the previous one
    let time = timePassed + i * 0.2;
    dots[i].y = 150 + Math.sin(time / period * 2 * Math.PI) * 25;
}

Here are the coin, the heart, the moon, Pac-Man and the dots together:

Make it bounce with squash and stretch

Squash and stretch is a trick that animators have used for almost a hundred years. A bouncing ball gets flatter when it hits the floor, and taller when it shoots up or falls down. In real life you'd hardly notice it, but in an animation it makes the ball feel soft and alive. It's the first of the twelve basic principles of animation from the Disney studio.

First, the bounce itself. A ball that bounces moves fast near the floor and slows down at the top. 4 * p * (1 - p) gives exactly that curve, for a progress value p from 0 to 1:

const bounceTime = 1;  // One bounce per second

timePassed += secondsPassed;
// The progress of the current bounce, from 0 to 1
let p = (timePassed % bounceTime) / bounceTime;

let height = 4 * p * (1 - p);     // 0 on the floor, 1 at the top
let speed = Math.abs(1 - 2 * p);  // 1 on the floor, 0 at the top

The % starts a new bounce every second. Now for the squash and stretch. Make the ball taller and thinner when it moves fast, and wider and flatter when it touches the floor. Keep the width times the height the same, so the ball doesn't seem to grow or shrink:

// 1 on the floor, 0 a little higher
let contact = Math.max(0, 1 - height / 0.15);

// Stretch when it moves fast, squash when it hits the floor
let scaleY = lerp(1 + 0.3 * speed, 0.7, contact);
let scaleX = 1 / scaleY;

contact is 1 when the ball touches the floor, and drops to 0 over the lowest 15% of the bounce. At full speed, scaleY makes the ball 30% taller, and on the floor, it squashes the ball to 70% of its height. scaleX does the opposite, so scaleX times scaleY stays 1, and the ball keeps the same area. For less squash and stretch, make 0.3 smaller and 0.7 bigger.

Then scale the ball from its bottom. Otherwise it squashes toward its center and floats above the floor:

context.save();
context.translate(ballX, floorY - height * 160);
context.scale(scaleX, scaleY);

// The bottom of the ball is at (0, 0)
context.beginPath();
context.arc(0, -radius, radius, 0, 2 * Math.PI);
context.fill();
context.restore();

ballX is the x-position of the ball, floorY is the y-position of the floor and radius is the radius of the ball. At the top of a bounce, the ball is 160 pixels above the floor.

Here is the result. The ball on the left just bounces, while the one on the right squashes and stretches:

Keep it subtle. A little squash and stretch makes a ball feel bouncy, but too much and it looks like jelly. You can use the same idea for a character that lands after a jump, or a button that gets pressed.

Keep your canvas sharp on every screen

Recap - The canvas setup tutorial covers devicePixelRatio too, with a demo that shows the difference between a blurry and a sharp canvas. Already know how to keep your canvas sharp? Then skip ahead to the next section.

Does your animation look a bit blurry on a phone, or on a laptop with a high-resolution screen? Chances are it's because of the pixels.

A canvas has its own pixels: 750 by 400 in this tutorial. But a modern screen often has 2 or even 3 real pixels for every CSS pixel. The browser then stretches your canvas to fill them, and stretching makes it blurry. window.devicePixelRatio tells you how many real pixels there are for each CSS pixel.

The fix is to give the canvas more pixels and to scale your drawing along with it. Here's how:

const gameWidth = 750;
const gameHeight = 400;
let ratio = 1;

function resizeCanvas() {
    // One canvas pixel for every pixel of the screen
    ratio = window.devicePixelRatio || 1;
    canvas.width = Math.round(canvas.clientWidth * ratio);
    canvas.height = Math.round(canvas.clientHeight * ratio);

    // Keep drawing in game coordinates (750 x 400)
    context.setTransform(
        canvas.width / gameWidth, 0,
        0, canvas.height / gameHeight,
        0, 0
    );
}

window.addEventListener('resize', resizeCanvas);

Call resizeCanvas() once in init(), the function from the canvas setup tutorial that gets the canvas and the context. Do this before the game loop starts. clientWidth and clientHeight give the size of the canvas on the page, in CSS pixels. The canvas gets that many pixels times the ratio, and setTransform() scales everything you draw. Your game keeps using a playing field of 750 by 400, so you don't have to change any of your drawing code.

resizeCanvas() only works when the size on the page comes from CSS, like the width: 100% rule from the canvas setup tutorial. Without a size in CSS, the size on the page follows canvas.width, so every call to resizeCanvas() makes the canvas bigger.

Here's the difference. The left half is drawn the old way, and the right half uses the device pixel ratio. On a regular screen with a ratio of 1, both halves look the same, but on a screen with a high pixel density, like those on most phones, tablets and many laptops, the left half is clearly blurry:

Keep in mind that a canvas with three times as many pixels in both directions has nine times as many pixels to draw. If your game gets slow on mobile devices, use Math.min(window.devicePixelRatio, 2) as the ratio. This will limit the ratio to 2, even when a screen supports more than 2.

Fix shaky edges with whole pixels

Here's another problem you might notice when something moves slowly. The edges of the rectangle seem to jitter a little, and thin lines flicker between sharp and soft. What's going on?

Your objects move based on time, so their position is almost never a whole number. At an x-position of 100.5, the edge of the rectangle falls in the middle of a pixel. The browser can't light up half a pixel, so it mixes the colors and the edge gets soft. This is called anti-aliasing. When the object moves, the mix changes every frame, and that's the jitter you see.

You can fix it by rounding the position when you draw. Only when you draw, so your game logic keeps the exact position:

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height);

    // Round to whole pixels, but keep the exact position in rectX
    context.fillStyle = '#ff8080';
    context.fillRect(Math.round(rectX), Math.round(rectY), 150, 100);
}

In the demo below, both blocks move at 6 pixels per second. The top one uses the exact position, and the bottom one is rounded. A magnifying glass over each block shows the part underneath it 8 times larger, so you can see the actual pixels on your screen:

Rounding has a downside: at very low speeds, you can see the shape jump one pixel at a time. So which approach looks better depends on what you're drawing. Thin lines and small details, like the stripes in the demo, look best on whole pixels. A big, plain shape that moves slowly can look smoother at its exact position, so try both. For pixel art you'll always want whole pixels. The canvas setup tutorial explains how to keep pixel art crisp when you scale it.

If you're using the device pixel ratio from the previous section, round to real pixels instead: Math.round(rectX * ratio) / ratio.

What makes an animation stutter?

Sometimes an animation runs fine and then suddenly hitches for a moment, or it just looks less smooth than it should. These are the usual suspects:

  • Too much work in one frame - At 60 Hz you have about 16.7 milliseconds per frame, at 120 Hz only 8.3. When update() and draw() take longer than that, the new frame isn't ready when the screen refreshes. So the screen shows the old frame again, and your animation hitches. The Performance panel in your browser's developer tools shows where the time goes.
  • New objects in every frame - Creating new arrays and objects in the game loop leaves a lot of garbage behind. Now and then the browser cleans it up, and that can cost a frame. Create your objects once and reuse them. For objects that come and go, like bullets or particles, keep a list of unused ones: take one out when you need it, and put it back when you're done. That's called an object pool.
  • Loading in the middle of the animation - An image that's drawn for the first time sometimes needs a moment to decode. Load everything before you start, like in the images and sprites tutorial.
  • Reading the page layout in the loop - Asking for sizes like clientWidth or getBoundingClientRect() every frame can make the browser recalculate the page. Read them once, and again when the window resizes.
  • Moving per frame - If you move a fixed number of pixels per frame, every skipped frame is a moment where nothing moves. Move based on time, as explained above.
  • Saving battery - Some devices lower the frame rate to save power, like an iPhone in Low Power Mode, which can go down to 30 fps. With time-based movement your animation keeps the right speed. It just looks a bit less smooth.

Respect the reduce motion setting

Not everybody enjoys things moving on a screen. For some people, big or fast motion causes dizziness or even nausea. That's why most systems have a setting to reduce motion, and the browser passes it on as prefers-reduced-motion. You can check it in JavaScript:

const reduceMotion =
    window.matchMedia('(prefers-reduced-motion: reduce)');

function update(secondsPassed) {
    // Jump straight to the end when the player prefers less motion
    if (reduceMotion.matches) {
        rectX = 550;
        return;
    }

    // ...
}

reduceMotion.matches stays up to date, so it also works when someone changes the setting while your page is open.

What you do with it is up to you. Skip an animation that's only there for decoration, make it shorter, or use a fade instead of a big movement. In a game the player still has to move around, so turn off things like screen shake and flashes instead, or let players turn them off in a settings menu.

Common questions about canvas animation

Should I use CSS or canvas for animations?

Use CSS to animate HTML elements, like buttons and menus. Use the canvas for games and for anything with a lot of moving objects. The overview at the top of this page explains the differences.

Why does my canvas animation leave a trail?

Because the canvas keeps everything you draw on it. Clear it at the start of every frame with clearRect(). If you do want a trail, paint over it with a see-through color instead.

Why does my canvas look blurry?

Most of the time, the canvas has fewer pixels than the screen shows. Multiply its size by window.devicePixelRatio and scale your drawing with setTransform(), as explained above.

How do I make an animation slow down at the end?

Use an ease-out function, like easeOutCubic(). Turn the time into a progress value from 0 to 1, pass it through the easing function and use lerp() to calculate the position.

How do I stop or pause a canvas animation?

Store the ID that requestAnimationFrame() returns and pass it to cancelAnimationFrame(). The tutorial about the JavaScript game loop shows how to start, pause and stop the loop.

The complete code

Here's everything put together. A ball moves from left to right and back with ease-in-out, changes color along the way and bounces with squash and stretch. It leaves a short trail. Above it, a square pops in with easeOutBack(), turning and fading in, and then floats up and down with Math.sin(). Everything is sharp on every screen and snaps to whole pixels. When the reduce motion setting is on, nothing moves. You can use it as a starting point for your own animation:

What's next?

That's all on canvas animation for now. You can move objects on the HTML5 canvas, clear it or leave a trail on purpose, and use easing functions to make the motion feel natural. You can also make them grow, turn, fade and change color, keep them floating and pulsing with Math.sin(), and make them bounce with squash and stretch. And you know how to base your animation on time, and how to keep it sharp and smooth on every screen. If you have any questions, feel free to ask them in the comment section. You can