Animating React Components

code
react

April 19, 2020

Let's make a react component that can be used to animate anything directly inside MDX files:

The above Animation uses a component called AnimationFrame which wraps the Plot component from last post:

<AnimationFrame autostart>
  {({ time }) => {
    const t = time.fromStart / 250;
    return (
      <Plot
        functions={[(x) => Math.sin(3 * x) * Math.sin(t / 2), (x) => Math.sin(2 * x) * Math.sin(t / 3)]}
        range={{ x: [0, Math.PI], y: [-1, 1] }}
        height={200}
        strokeWidth={4}
        hideXAxis={true}
      />
    );
  }}
</AnimationFrame>

AnimationFrame component

The AnimationFrame component is relatively straightforward as it essentially just uses a custom hook called useFrame:

export default function AnimationFrame(props) {
  const [time, setTime] = useState({
    fromStart: 0,
    fromFirstStart: 0,
    progress: 0,
    delta: null,
  });
  const frame = useFrame(setTime, props.autostart);
  return props.children({ ...frame, time });
}

useFrame

The more interesting useFrame hook:


export default function useFrame(callback, autostart = false) {
  const requestRef = useRef<number>();
  const previousTimeRef = useRef<number>();
  const startTimeRef = useRef<number>();
  const stopTimeRef = useRef<number>(0);
  const maxTimeRef = useRef<number>();
  const [isRunning, setIsRunning] = useState(autostart);

  const animate = time => {
    if (!startTimeRef.current) {
      startTimeRef.current = time;
    }
    if (previousTimeRef.current != undefined) {
      const delta = time - previousTimeRef.current
      const fromStart = time - startTimeRef.current;
      callback({
        time,
        delta,
        fromStart,
        fromFirstStart: fromStart + stopTimeRef.current,
        stopTime: stopTimeRef.current,
        progress: maxTimeRef.current ? fromStart / maxTimeRef.current : 0
      });
      if (maxTimeRef.current && fromStart >= maxTimeRef.current) {
        stop();
        maxTimeRef.current = null;
        return;
      }
    }
    previousTimeRef.current = time;
    requestRef.current = requestAnimationFrame(animate);
  }

  function start(maxTime?) {
    if (isRunning) {
      stop();
    }
    if (maxTime) {
      maxTimeRef.current = maxTime;
    }
    requestRef.current = requestAnimationFrame(animate);
    setIsRunning(true);
  }

  function stop() {
    if (previousTimeRef.current && startTimeRef.current) {
      stopTimeRef.current = previousTimeRef.current - startTimeRef.current + stopTimeRef.current;
    }
    cancelAnimationFrame(requestRef.current);
    startTimeRef.current = 0;
    requestRef.current = null;
    setIsRunning(false)
  }
  function toggle() {
    if (!requestRef.current) {
      start()
    } else {
      stop();
    }
  }

  React.useEffect(() => {
    autostart && start();
    return () => stop()
  }, []); // Make sure the effect runs only once
  return { start, stop, toggle, isRunning };
}

In its essence, it takes a callback that is repeatedly invoked inside requestAnimationFrame. When start is called, the animation begins.

Perspectives on time

The callback is passed different perspectives on time:

  • time: total time elapsed since document creation (default requestAnimation param)
  • delta: time elapsed since last frame e.g. 17ms for 60fps
  • fromStart: time elapsed since animation was started
  • fromFirstStart: time elapsed since animation was started the first time
  • stopTime: the last time the animation was stopped

fromStart and fromFirstStart should be the most interesting for most use cases:

time.fromStart: 0s

time.fromFirstStart: 0s

time.delta: 0ms = Infinityfps


progress

We can turn the continuus animation to a timed one by passing a maxTime to the start method. If doing so, the callback param progress will contain a number between 0 and 1.

0%

React Animation libs

Of course, there are several libs for animating in the react world:

But they are mainly focused on one time animations rather than on infinite time flow. Only react spring would allow never ending animations using a spring tension of 0, but still the values are sprining between number bounds that are only indirectly controllable via spring constants.

d3-transition

When using d3, we could use d3-transition, but this is only possible in combination with d3-selection, which I try to avoid, as I want to control the DOM from React as direct as possible. Also, like with the libs above, it is not really elegant to do infinite animations

TBD

Felix Roos 2023