JavaScript animations

CSS transitions and keyframes cover most everyday motion, and the browser runs them efficiently. But CSS has hard limits. When you need to move an element along a winding path, drive a <canvas>, or use a timing curve that no Bezier can express, you drop down to JavaScript.

The payoff is total control. You decide, frame by frame, exactly where things are and what they look like. The cost is that you now own the timing loop, so it pays to get the mechanics right.

Using setInterval

An animation is a sequence of frames. Each frame is a small change to some HTML or CSS property, and if the changes come fast enough, your eye stitches them into continuous motion.

Say you nudge style.left from 0px toward 100px. Move it by 2px on a short repeating timer, roughly 50 times a second, and the element glides across the screen. Film works the same way: around 24 frames per second is enough for the brain to read separate images as movement.

t=0
left:0
t=20ms
left:2
t=40ms
left:4
t=60ms
left:6
Each tick redraws one frame; the eye blends them into motion.

In pseudo-code the skeleton is short:

let timer = setInterval(function() {
  if (animation complete) clearInterval(timer);
  else increase style.left by 2px
}, 20); // change by 2px every 20ms, about 50 frames per second

That “is it done yet?” check is doing real work. Without clearInterval, the timer keeps firing forever, burning CPU on an element that already reached its target.

Here’s a fuller version. Notice that it does not count frames or assume each tick lands exactly 20ms after the last. It reads the real clock and computes position from elapsed time:

let start = Date.now(); // remember start time

let timer = setInterval(function() {
  // how much time passed from the start?
  let timePassed = Date.now() - start;

  if (timePassed >= 2000) {
    clearInterval(timer); // finish the animation after 2 seconds
    return;
  }

  // draw the animation at the moment timePassed
  draw(timePassed);

}, 20);

// as timePassed goes from 0 to 2000
// left gets values from 0px to 400px
function draw(timePassed) {
  box.style.left = timePassed / 5 + 'px';
}

Click for the demo:

interactiveTime-based motion with setInterval

Using requestAnimationFrame

Now picture several animations running at once. Each one calls setInterval(..., 20), but they were started at different moments, so their ticks are staggered. The browser ends up repainting far more often than every 20ms.

Three separate setInterval calls (staggered):
anim1
||||
anim2
||||
anim3
||||
repaint
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ (many)
One requestAnimationFrame tick per frame (aligned):
all 3
||||
repaint
Three unaligned intervals force many repaints; one shared tick forces one.

Grouping is cheaper. This single timer does three jobs on one schedule:

setInterval(function() {
  animate1();
  animate2();
  animate3();
}, 20)

…and it’s lighter than three timers scattered across the codebase:

setInterval(animate1, 20); // independent animations
setInterval(animate2, 20); // in different places of the script
setInterval(animate3, 20);

Fewer independent redraws means the browser recalculates geometry and repaints fewer times, which lowers CPU load and looks smoother.

There’s a second problem setInterval can’t solve on its own. Sometimes the browser shouldn’t redraw at 20ms intervals at all — the CPU is overloaded, or the tab is hidden in the background where nobody can see the animation. Your JavaScript has no clean way to know any of that.

The Animation timing specification answers it with requestAnimationFrame. The browser owns the schedule, aligns your callback with its own paint cycle, and skips the work entirely when the tab isn’t visible.

let requestId = requestAnimationFrame(callback)

This asks the browser to run callback at the next moment it’s about to paint. Changes you make inside the callback get batched with every other requestAnimationFrame callback and with CSS animations, so the browser does one geometry recalculation and one repaint for the whole frame instead of many.

The return value is a handle you can use to cancel a pending call:

// cancel the scheduled execution of callback
cancelAnimationFrame(requestId);

Your callback receives one argument: the number of milliseconds since the page started loading. It’s the same clock you’d get from performance.now(), a high-resolution timestamp. Most of the time the callback fires promptly. It can lag when the CPU is swamped, the laptop battery is nearly dead, or the browser has other reasons to hold back.

The snippet below prints the gap between the first 10 frames. On a healthy machine you’ll usually see 10–20ms:

<script>
  let prev = performance.now();
  let times = 0;

  requestAnimationFrame(function measure(time) {
    document.body.insertAdjacentHTML("beforeEnd", Math.floor(time - prev) + " ");
    prev = time;

    if (times++ < 10) requestAnimationFrame(measure);
  })
</script>

Run it yourself and read the gaps — each number is the milliseconds between two consecutive frames:

interactiveMeasuring the gap between real frames

Structured animation

With requestAnimationFrame as the engine, you can build one general-purpose animation function and reuse it everywhere:

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction goes from 0 to 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // calculate the current animation state
    let progress = timing(timeFraction)

    draw(progress); // draw it

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

The design cleanly separates three concerns: how long, the shape of the motion over time, and what to actually paint. Each frame it measures elapsed time as a fraction, converts that fraction into a progress value through the timing curve, then hands progress to the drawing step.

time − start
ms elapsed
timeFraction
0 … 1
timing(f)
curve
progress
usually 0 … 1
draw(progress)
paint frame
if timeFraction < 1 → requestAnimationFrame(animate) again ↺
One frame of the animate loop: clock → time fraction → timing curve → progress → draw.

The function takes three parameters that together describe the animation.

duration — total running time of the animation, in milliseconds. For example 1000.

timing(timeFraction) — the timing function, the JavaScript twin of the CSS transition-timing-function. It receives the fraction of elapsed time (0 at the start, 1 at the end) and returns how far along the animation should be (like the y value on a Bezier curve). The plainest one moves at a constant speed:

function linear(timeFraction) {
  return timeFraction;
}

Its graph is a straight diagonal:

10time fraction
linear: progress rises in a straight diagonal — constant speed from start to finish.

That matches transition-timing-function: linear. The more interesting curves come later.

draw(progress) — the function that renders a given progress value. progress = 0 is the start state, progress = 1 is the end state. This is where the visible change actually happens. It might move an element:

function draw(progress) {
  box.style.left = progress + 'px';
}

…or do anything at all. Nothing forces draw to touch left, or even CSS.

Here’s the whole thing animating an element’s width from 0 to 100%. Click the bar for the demo:

interactiveanimate() filling a bar's width

The code:

animate({
  duration: 1000,
  timing(timeFraction) {
    return timeFraction;
  },
  draw(progress) {
    elem.style.width = progress * 100 + '%';
  }
});

This is where JavaScript pulls ahead of CSS. The timing function can be any math you like, not just a Bezier curve, and draw can go well beyond setting a property — it could spawn new elements for a fireworks burst or repaint a canvas.

Timing functions

The linear function is the simplest possible curve. Now for the ones with character. Each of these is a timing(timeFraction) you can drop straight into animate, and each produces a different feel of motion.

Power of n

Raise progress to a power to make the motion start slow and accelerate. Squaring it gives a parabola:

function quad(timeFraction) {
  return Math.pow(timeFraction, 2)
}

The graph:

10time fraction
quad: a parabola — the curve hugs the baseline early, then sweeps upward toward the end.

See it in action (click to activate):

interactivequad timing — starts slow, accelerates

Push the exponent higher — cube it, or go to the fifth power — and the acceleration gets steeper: it crawls at the start and lunges at the end. Here’s progress to the power 5:

10time fraction
power of 5: an even flatter start and a far steeper finish than quad.

In action:

interactivepower of 5 — a steeper accelerate

The arc

This one traces a circular arc, easing in along the curve of a circle:

function circ(timeFraction) {
  return 1 - Math.sin(Math.acos(timeFraction));
}

The graph:

10time fraction
circ: a quarter of a circle — a gentle start that steepens sharply right at the finish.
interactivecirc timing — the circular arc

Back: bow shooting

This function mimics drawing a bow. It first pulls backward — the value dips below zero — then releases and shoots forward past the target.

Unlike the earlier functions, it takes an extra parameter x, the “elasticity coefficient”, which controls how far the bowstring pulls back.

function back(x, timeFraction) {
  return Math.pow(timeFraction, 2) * ((x + 1) * timeFraction - x)
}

The graph for x = 1.5:

10time fraction
back (x = 1.5): the curve dips below zero first — the bow pulling back — then launches upward past the target line.

Because back needs two arguments, you bind a specific x before handing it to animate (for example, wrap it so timing(timeFraction) calls back(1.5, timeFraction)). Example for x = 1.5 — watch the box pull backward before it launches:

interactiveback timing — draw the bow, then release

Bounce

Drop a ball: it hits the floor, bounces up, falls, bounces again a little lower, and settles. The bounce function produces that pattern — though the raw form does it front-loaded, with the bounces packed at the start. It leans on a few tuned coefficients:

function bounce(timeFraction) {
  for (let a = 0, b = 1; 1; a += b, b /= 2) {
    if (timeFraction >= (7 - 4 * a) / 11) {
      return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
    }
  }
}

The for (…; 1; …) header loops until one of the if branches returns, walking through progressively smaller bounce segments. Here it drives a box’s vertical position so you can see it drop and settle (bounces packed at the start):

interactivebounce timing — a dropping ball

Elastic animation

An “elastic” wobble. It also takes an extra x, here the “initial range” of the oscillation:

function elastic(x, timeFraction) {
  return Math.pow(2, 10 * (timeFraction - 1)) * Math.cos(20 * Math.PI * x / 3 * timeFraction)
}

The graph for x = 1.5:

10time fraction
elastic (x = 1.5): tiny oscillations around zero that grow into a big swing, then settle on the target.

In action for x = 1.5 — the box wobbles into place:

interactiveelastic timing — a springy wobble

Reversal: ease*

You now have a small library of timing functions. Feeding one straight into animate gives what’s called the easeIn behavior: the effect is concentrated at the beginning.

Often you want the mirror image — the effect at the end. That’s the easeOut transform. And you can have both ends at once with easeInOut. The neat part is that these are transforms: you write them once and apply them to any timing function.

easeIn
effect at the start
timing(f)
easeOut
effect at the end
1 − timing(1 − f)
easeInOut
effect at both ends
easeIn first half,
easeOut second half
Three ways to place the effect in time — same base curve, different arrangement.

easeOut

To flip the effect to the end, wrap the timing function like this:

timingEaseOut(timeFraction) = 1 - timing(1 - timeFraction)

Reading it out loud: run time backward (1 - timeFraction), pass that through the original curve, then flip the result (1 - …). The dual reversal turns a start-heavy curve into an end-heavy one. Packaged as a transform:

// accepts a timing function, returns the transformed variant
function makeEaseOut(timing) {
  return function(timeFraction) {
    return 1 - timing(1 - timeFraction);
  }
}

makeEaseOut is a higher-order function — it takes a function and returns a new one wrapped around it. Apply it to bounce:

let bounceEaseOut = makeEaseOut(bounce);

Now the bouncing lands at the end of the animation instead of the start, which usually reads better:

interactivemakeEaseOut(bounce) — bounces settle at the end

The transform’s effect on the curve:

10bounceeaseOut
Regular bounce (red) packs the bounces at the start; makeEaseOut(bounce) (blue) mirrors it so they land at the end.

Whatever effect the base curve puts at the beginning, easeOut moves to the end. In the graph above the regular bounce is drawn in red and the easeOut bounce in blue:

  • Regular bounce — the object bounces near the bottom, then jumps sharply to the top right at the finish.
  • After easeOut — it jumps to the top first, then bounces there.

easeInOut

To show the effect at both ends, split the timeline in half. The first half runs the base curve (easeIn), the second half runs the reversed curve (easeOut), each scaled into its half:

if (timeFraction <= 0.5) { // first half of the animation
  return timing(2 * timeFraction) / 2;
} else { // second half of the animation
  return (2 - timing(2 * (1 - timeFraction))) / 2;
}

As a transform:

function makeEaseInOut(timing) {
  return function(timeFraction) {
    if (timeFraction < .5)
      return timing(2 * timeFraction) / 2;
    else
      return (2 - timing(2 * (1 - timeFraction))) / 2;
  }
}

bounceEaseInOut = makeEaseInOut(bounce);

In action, bounceEaseInOut — bounces at both ends:

interactivemakeEaseInOut(bounce) — bounces at both ends

So easeInOut stitches two graphs together: easeIn (regular) across the first half and easeOut (reversed) across the second. The contrast is clearest when you overlay the easeIn, easeOut, and easeInOut versions of circ:

10easeIneaseOuteaseInOut
The same circ base curve, arranged three ways: easeIn (red), easeOut (green), and easeInOut (blue).
  • Red is the plain circ (easeIn).
  • Green is its easeOut.
  • Blue is easeInOut.

The first half of the blue curve is a scaled-down easeIn and the second half is a scaled-down easeOut, so the animation opens and closes with the same gentle motion.

More interesting “draw”

Moving an element is just one possible draw. Swap in different rendering logic and the same animate engine drives something else entirely — here, text that types itself in with a bouncing feel. Notice draw touches textContent, not any CSS property:

interactiveA draw that types text instead of moving pixels

The timing function and the drawing function are fully independent. Any curve can drive any renderer.

Summary

When CSS can’t express the motion you need, or you want frame-level control, reach for JavaScript. Drive the animation with requestAnimationFrame: you register a callback and the browser runs it just before its next repaint. That’s usually very soon, but the exact timing is the browser’s call.

The background-tab behavior is a real feature, not a footnote. When the page isn’t visible the browser stops repainting, so your callback stops firing — the animation pauses and stops consuming resources until the tab is shown again.

The reusable helper for most cases:

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction goes from 0 to 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // calculate the current animation state
    let progress = timing(timeFraction);

    draw(progress); // draw it

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

Its options:

  • duration — total animation time in ms.
  • timing — computes progress from a time fraction (0 to 1), returning progress, usually in the 0 to 1 range.
  • draw — renders the animation at a given progress.

You could bolt on more features, but JavaScript animations aren’t an everyday tool. They exist for the unusual, custom effects that CSS won’t do — so add the extras when a specific animation actually calls for them.

Two freedoms are worth remembering. The timing function can be any curve, not only a Bezier, and thanks to the easeIn/easeOut/easeInOut transforms you can reshape any of them. And draw can animate anything at all, not just CSS properties.