Skip to content

Instantly share code, notes, and snippets.

@jakearchibald
Last active June 2, 2025 12:23
Show Gist options
  • Save jakearchibald/cb03f15670817001b1157e62a076fe95 to your computer and use it in GitHub Desktop.
Save jakearchibald/cb03f15670817001b1157e62a076fe95 to your computer and use it in GitHub Desktop.
export 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();
function frame(time) {
if (signal.aborted) return;
callback(time);
scheduleFrame(time);
}
function scheduleFrame(time) {
const elapsed = time - start;
const roundedElapsed = Math.round(elapsed / ms) * ms;
const targetNext = start + roundedElapsed + ms;
const delay = targetNext - performance.now();
setTimeout(() => requestAnimationFrame(frame), delay);
}
scheduleFrame(start);
}
// Usage
import { animationInterval } from './1.js';
const controller = new AbortController();
// Create an animation callback every second:
animationInterval(1000, controller.signal, time => {
console.log('tick!', time);
});
// And to stop it:
controller.abort();
@jakearchibald
Copy link
Author

If your thread is blocked, then both setTimeout and requestAnimationFrame callbacks will be delayed. Avoid running long tasks on the UI thread.

@millsp
Copy link

millsp commented Aug 31, 2024

I came up with a similar algo that compensates clock skew as it goes. It will compensate end to end for slight delays in setTimeout, requestAnimationFrame, and of course callback executions. So if any of these skew the clock, it will tell the next run to execute a bit earlier. As a bonus, I also made it compatible with useEffect for react users.

type ExecutionControl = { stopped?: true; animationId?: number; timeoutId?: NodeJS.Timeout };

export function interval(cb: () => void, ms: number, exeCtl: ExecutionControl = {}) {
  if (exeCtl.stopped) return;

  const start = performance.now();
  exeCtl.animationId = requestAnimationFrame(() => {
    exeCtl.timeoutId !== undefined && cb();
    const end = performance.now();
    const diff = Math.round(end - start);
    const timeout = Math.max(0, ms - diff);

    exeCtl.timeoutId = setTimeout(() => {
      return interval(cb, ms, exeCtl);
    }, timeout);
  });

  return () => {
    exeCtl.stopped = true;
    exeCtl.timeoutId && clearTimeout(exeCtl.timeoutId);
    exeCtl.animationId && cancelAnimationFrame(exeCtl.animationId);
  };
}

@jakearchibald
Copy link
Author

Fwiw, the OP solution isn't incompatible with useEffect.

useEffect(() => {
  const controller = new AbortController();

  animationInterval(1000, controller.signal, () => {
    // Runs every second
  });

  return () => controller.abort();
}, [...whatever]);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment