Octocat

useTicker

A hook for creating performant paint-dependent loops using requestAnimationFrame with precise timing and delta calculations

Source Code

import { useCallback, useEffect, useMemo, useRef } from 'react';

type Ticker = {
  start: () => void;
  stop: () => void;
  paused: boolean;
};

type TickerCallback = (
  timestamp: number,
  delta: number
  // biome-ignore lint/suspicious/noConfusingVoidType: intentional
) => void | undefined | boolean;

const MIN_DELTA = 0.000000001;

export const useTicker = (callback: TickerCallback): Ticker => {
  const rafId = useRef<number | null>(null);
  const previousTimestamp = useRef(0);
  const callbackRef = useRef(callback);
  const isRunning = useRef(false);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  const tick = useCallback((timestamp: number) => {
    if (!isRunning.current) return;

    const delta = Math.max(MIN_DELTA, timestamp - previousTimestamp.current);
    const shouldContinue = callbackRef.current(timestamp, delta);

    previousTimestamp.current = timestamp;

    if (shouldContinue !== false) {
      rafId.current = window.requestAnimationFrame(tick);
    } else {
      isRunning.current = false;
      rafId.current = null;
    }
  }, []);

  const start = useCallback(() => {
    if (rafId.current) {
      window.cancelAnimationFrame(rafId.current);
    }

    isRunning.current = true;
    previousTimestamp.current = performance.now();
    rafId.current = window.requestAnimationFrame(tick);
  }, [tick]);

  const stop = useCallback(() => {
    if (rafId.current) {
      window.cancelAnimationFrame(rafId.current);
      rafId.current = null;
    }

    isRunning.current = false;
  }, []);

  useEffect(() => {
    return () => {
      stop();
    };
  }, [stop]);

  return useMemo(
    () => ({
      start,
      stop,
      get paused() {
        return !isRunning.current;
      },
    }),
    [start, stop]
  );
};

API Reference

Prop Default Type Description
callback * - (timestamp: number, delta: number) => void | boolean The callback to call on each tick.

The hook returns an object with the following properties:

  • start: A function to start the ticker.
  • stop: A function to stop the ticker.
  • paused: A boolean indicating if the ticker is paused.

Examples

Looping Animation

'use client';

import {
  ArrowCounterClockwiseIcon,
  PlayIcon,
  SquareIcon,
} from '@phosphor-icons/react';
import { useCallback, useEffect, useRef } from 'react';

import { useTicker } from '@/foundations/hooks/use-ticker';
import { Button } from '@/components/button';

const UseTickerCanvasAnimation = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const timeElapsed = useRef(0);

  const ticker = useTicker((_timestamp, delta) => {
    timeElapsed.current += delta;

    if (canvasRef.current) {
      renderFrame(canvasRef.current, timeElapsed.current);
    }
  });

  const reset = useCallback(() => {
    timeElapsed.current = 0;

    if (canvasRef.current) {
      renderFrame(canvasRef.current, timeElapsed.current, true);
    }
  }, []);

  useEffect(() => {
    reset();
  }, [reset]);

  return (
    <div className="absolute inset-0 grid place-items-center">
      <div className="absolute top-2 left-2 z-10 flex justify-start gap-2">
        <Button onClick={ticker.start} size="sm">
          <PlayIcon size={16} />
          Start
        </Button>
        <Button variant="outline" onClick={ticker.stop} size="sm">
          <SquareIcon size={16} />
          Stop
        </Button>
        <Button variant="outline" onClick={reset} size="sm">
          <ArrowCounterClockwiseIcon size={16} />
          Reset
        </Button>
      </div>
      <canvas
        ref={canvasRef}
        width={640}
        height={480}
        className="absolute h-full mix-blend-multiply"
      />
    </div>
  );
};

function renderFrame(
  canvas: HTMLCanvasElement,
  progress: number,
  clearCanvas?: boolean
) {
  const context = canvas.getContext('2d');
  if (!context) return;

  const angle = (progress * 0.002) % Math.PI;

  const { width, height } = canvas;
  const radius = 24;
  const y = 1 - Math.sin(angle) * 0.8;

  if (clearCanvas) {
    context.clearRect(0, 0, width, height);
  }

  // cover canvas with white at 0.33 alpha to get the trailing effect
  context.beginPath();
  context.rect(0, 0, width, height);
  context.fillStyle = 'rgba(255, 255, 255, 0.33)';
  context.fill();

  // draw circle
  context.beginPath();
  context.arc(
    0.5 * width,
    radius + y * (height - 2 * radius),
    radius,
    0,
    Math.PI * 2
  );
  context.fillStyle = '#222';
  context.fill();
}

export default UseTickerCanvasAnimation;

About requestAnimationFrame

requestAnimationFrame is a browser API that schedules a callback to be executed before the next browser repaint. It’s the recommended way to create animations and game loops as it automatically synchronizes with the browser’s refresh rate.

You can read a more in-depth overview of requestAnimationFrame on our blog: Mastering JavaScript Web Animations.

Previous

useTailwindBreakpoint

Next

useTopLayer