JSFiddle - React, Tailwind, and code Playground
HTML
<canvas id="canvas"></canvas>
<p>
<span id="counter_missed">0</span><span> missed frames.</span>
</p>
<script>
function animationInterval(ms, signal, callback) {
// Prefer currentTime, as it'll better sync animtions queued in the
// same frame, but if it isn't supported, performance.now() is fine.
const start = document.timeline ? document.timeline.currentTime : performance.now();
// To avoid scheduling rAF in the following frame,
// we need to have some safety reverse-delay.
// The best would be to know the current monitors refresh-rate
// or to force it, but we currently can't.
// so we assume 240Hz max (above that, the delay might not be enough...)
const magic_safe_delay = 1000 / 240;
function frame(time) {
if (signal.aborted) return;
callback(time);
scheduleFrame(time);
}
function scheduleFrame(time) {
const elapsed = time - start;
// floor because setTimeout is never too early
const flooredElapsed = Math.floor(elapsed / ms) * ms;
const targetNext = start + flooredElapsed + ms;
// all this can probably be just simplified to
// const targetNext = Math.floor(time + ms);
const delay = targetNext - performance.now();
setTimeout(() => requestAnimationFrame(frame), delay - magic_safe_delay);
}
scheduleFrame(start);
}
</script>
JavaScript
const FPS = 60; // set it to your own monitor's refresh-rate
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
let fired = false; // a simple flag to check we correctly fired at each animation frame
let i = 0, j = 0; // the x-axis position of our two rects
// A "pure" rAF loop
// Note that we start with this one
// because animationInterval always pushes its rAF callback at the end of
// the animation callbacks map (thanks to setTimeout)
anim = () => {
// animationInterval didn't kick in during last frame
if( fired === true ) {
counter_missed.textContent ++;
}
fired = true;
// update the first rect's x-axis position
i = (i + 1) % canvas.width;
// we know we are the first in the animation callbacks map,
// so we can clear the whole context
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw the top rectangle (x, y, width, height)
ctx.fillRect(i, 10, 50, 50);
requestAnimationFrame(anim);
};
requestAnimationFrame(anim);
// Now animationInterval, which if it was trully "in sync with the display"
// and if the FPS value was higher or equal to the monitor's refresh-rate
// should fire just as many times as 'anim' does
const controller = new AbortController();
animationInterval(1000 / FPS, controller.signal, () => {
// set the flag down, to let 'anim' know we kicked in
fired = false;
// update the second rect's x-axis position
j = (j + 1) % canvas.width;
// draw the bottom rectangle (x, y, width, height)
ctx.fillRect(j, 80, 50, 50);
} );