JS Tip of the Day: Time-based requestAnimationFrame

Time-based requestAnimationFrame
Level: Intermediate

When using requestAnimationFrame you get optimized callbacks for handling animations in a performant manner. The way these callbacks are managed, however, doesn’t guarantee a consistently spaced apart set of calls. The timing between callbacks may differ as the resources available for rendering change, or simply as a result of running on slower devices. To keep your animations looking smooth and consistent you should base your animations on time, something requestAnimationFrame is already inherently set up to help you do.

Callbacks used with requestAnimationFrame get a timestamp argument, a high-precision value in milliseconds representing the time since the page was loaded. Conveniently, this value can then be used to drive time-based animations.

function callback (timestamp) {
    // animate stuff
}
requestAnimationFrame(callback);

For any kind of animation that is (or can be) based on a consistently incrementing value, you can refer to the timestamp value directly. Consider ball moving along a sine wave. This will consist of a steady forward motion along the x-axis combined with a oscillating up and down (sine wave) motion along the y-axis. Both of these can be directly based on the timestamp value given to the requestAnimationFrame callback.

<span id="ball" style="position: absolute">O</span>

<script>
let ball = document.getElementById('ball');
let bounds = {x: 300, y: 100};

function sineLoop (timestamp) {
    let x = timestamp / 25; // moderate speed
    x %= bounds.x; // wrap back to 0
    let sine = Math.sin(timestamp / 750); // moderate speed
    let y = bounds.y * (1 + sine) / 2;
    ball.style.transform = `translate(${x}px, ${y}px)`;
    
    requestAnimationFrame(sineLoop);
}
requestAnimationFrame(sineLoop);
</script>

Because this animates based on time, no matter how fast or slow requestAnimationFrame runs, the animation will consistently render in the correct place with consistent timing and won’t appear to slow down if the rate at which rendering occurs changes. You can even test this out yourself by delaying subsequent requestAnimationFrame calls with setTimeout().

<span id="ball" style="position: absolute">O</span>

<script>
let ball = document.getElementById('ball');
let bounds = {x: 300, y: 100};

function sineLoop (timestamp) {
    let x = timestamp / 25;
    x %= bounds.x;
    let sine = Math.sin(timestamp / 750);
    let y = bounds.y * (1 + sine)/2;
    ball.style.transform = `translate(${x}px, ${y}px)`;
    
    setTimeout(requestAnimationFrame, 200, sineLoop); // delay
}
requestAnimationFrame(sineLoop);
</script>

Because the animation is time based, even with this delay in rendering, it still animates at the same pace as it did before, just with fewer updates.

Of course not all animations can be directly based on an ever-increasing value as seen above. If instead you have an animation that, for example, may rely on a certain speed value, you can alternately use timestamp to generate a modifier for that speed in order to make it time-based. All you need to do is see how much time has elapsed since the last requestAnimationFrame callback was called.

<span id="ball" style="position: absolute">O</span>

<script>
let ball = document.getElementById('ball');
let ballProps = {x: 0, speed: 2, bounds: 300};

let lastTimestamp = performance.now(); // current timestamp value
function ballLoop (timestamp) {
    let timeDiff = timestamp - lastTimestamp;
    let speedFactor = timeDiff * 60/1000; // based on 60 fps
    lastTimestamp = timestamp;
    
    let { x, speed, bounds } = ballProps;
    x += speed * speedFactor; // modify based on time
    x %= bounds; // wrap back to 0
    ballProps.x = x;
    ball.style.transform = `translateX(${x}px)`;
    
    requestAnimationFrame(ballLoop);
}
requestAnimationFrame(ballLoop);
</script>

In this example, the ball element’s position increases every frame by a base value of 2 pixels. You can imagine that in an ideal situation where frames consistently ran at 60 frames per second, incrementing x by this value alone would result in a smooth and consistent animation. However, if something were to happen that throttled the frames to, say, 30 fps, the ball would be moving at half speed. This is where the speedFactor comes into play - a modifier based on the time elapsed between each requestAnimationFrame callback that adjusts the speed to keep it consistent with a 60 fps animation even when render updates may not be happening that fast.

More info:

1 Like