requestAnimationFrame(callback) runs the callback before the next repaint with a timestamp; calling it again from inside makes a loop at the display's rate that pauses in background tabs, replacing old setTimeout() loops. Move things by elapsed time, not frame count.
<canvas id="c" style="width:600px; height:80px"></canvas><script src="hidpi.js"></script>
<pre id="out" style="margin:0"></pre>
<script>
const ctx = hidpi(c); let x = 0, start, last, frames = 0;
function frame(now) {
start ??= now; const dt = (now - (last ?? now)) / 1000; last = now; // seconds
ctx.fillStyle = 'rgb(255 255 255 / 0.3)'; ctx.fillRect(0, 0, 600, 80); // fade
x = Math.min(x + 240 * dt, 540); frames++;
ctx.save(); ctx.translate(30 + x, 40); ctx.rotate(x / 60);
ctx.fillStyle = '#b5452f'; ctx.fillRect(-12, -18, 24, 36); ctx.restore();
if (x < 540) return requestAnimationFrame(frame);
out.textContent = `${frames} frames in ${((now - start) / 1000).toFixed(2)} s`;
__done = true;
}
requestAnimationFrame(frame);
</script>
A translucent white fill instead of a clear leaves the trail. Clamp dt after a pause, or a returning tab makes objects jump.
<!doctype html>
<style>
body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; }
canvas { width: 100%; max-width: 600px; height: 160px; display: block; background: #fff; border: 1px solid #ddd; }
</style>
<canvas id="c"></canvas>
<p id="out"></p>
<script>
function hidpi(c, r = devicePixelRatio) {
c.width = c.clientWidth * r; c.height = c.clientHeight * r;
const ctx = c.getContext('2d'); ctx.scale(r, r); return ctx;
}
const ctx = hidpi(document.getElementById('c'));
const out = document.getElementById('out');
// Three books with speeds in pixels per second, independent of the display's frame rate
const books = [
{ x: 30, y: 40, speed: 240, color: '#b5452f' },
{ x: 30, y: 80, speed: 160, color: '#1f5f8b' },
{ x: 30, y: 120, speed: 90, color: '#3f7d3a' }
];
let last, frames = 0, elapsed = 0;
function frame(now) {
// Seconds since the previous frame, clamped so a returning tab doesn't make books jump
const dt = Math.min((now - (last ?? now)) / 1000, 0.1);
last = now; frames++; elapsed += dt;
// A translucent white fill instead of clearRect leaves a trail
ctx.fillStyle = 'rgb(255 255 255 / 0.3)'; ctx.fillRect(0, 0, 600, 160);
for (const b of books) {
b.x += b.speed * dt;
if (b.x > 580) b.x = 20; // wrap around
ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(b.x / 60);
ctx.fillStyle = b.color; ctx.fillRect(-10, -15, 20, 30);
ctx.restore();
}
if (frames % 30 === 0) out.textContent = `${frames} frames in ${elapsed.toFixed(2)} s ≈ ${Math.round(frames / elapsed)} fps`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>