useRainbow Hook

Published on December 19, 2023


Let's put together a simple hook in React that performs linear interpolation through the color wheel, giving a rainbow effect.

We'll end up with a "useRainbow" hook that we can use to animate the color of anything on the page.

By leveraging the requestAnimationFrame API we can make these updates in a performant manner.

There's also some deceptive complexity to the seemingly simple task of "animating through all the colors" - you can either take the shortest path between those two colors or the longest path. In this case, we want to take the long path since we're trying to emulate a rainbow.

To make things easy on ourselves, we will leverage the interpolator provided by the d3-interpolate package.

The Hook

// d3-interpolate handles the linear interpolation math
import { interpolateHslLong } from 'd3-interpolate';
import { useEffect, useMemo, useRef, useState } from 'react';

export default function useRainbow(
  startColor = '#ff0000', // start at red
  endColor = '#0000ff', // end at blue
  timeDilation = 2000 // slow time down by a factor of 2000, this depends on your CPU/GPU
) {
  // interpolateHslLong takes two colors and returns a function which can interpolate between them.
  // here, useMemo helps us only recalculate the interpolation function if/when the input colors change
  const interpolator = useMemo(
    () => interpolateHslLong(startColor, endColor),
    [startColor, endColor]
  );
  // requestId will be used to store the temporary ID assigned to an in-flight animation frame request.
  // if the component unmounts, we will cancel this in-flight request to stop the render loop.
  const requestId = useRef(null);
  // this stores the current color which this hook returns directly
  const [color, setColor] = useState(null);

  // this function is called every "frame" and updates the output color
  const animate = (time) => {
    // (time / timeDilation) will increase monotonically. Pass it into abs(sin()) to convert it to a
    // infinite sweep between 0 and 1. This is the input range for the interpolator function.
    setColor(interpolator(Math.abs(Math.sin(time / timeDilation))));

    // this is what causes the rendering to loop "infinitely"
    requestId.current = requestAnimationFrame(animate);
  };

  useEffect(() => {
    // kick off the first animation frame request
    requestId.current = requestAnimationFrame(animate);

    // if the component unmounts, cancel the render loop
    return () => cancelAnimationFrame(requestId.current);
  }, []);

  // return the current color
  return color;
}

Example Usage

import useRainbow from 'hooks/useRainbow';

export default function Component() {
  const color = useRainbow();

  return <p style={{ color }}>Hello World</p>;
}

Demonstration

Hello World

Going Further

Depending on your requirements, this hook may be complete. However, what if we also needed to pause/unpause the animation based on some user input?

import { interpolateHslLong } from 'd3-interpolate';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';

export default function useRainbow(
  run = true, // whether to start running or not
  startColor = '#ff0000',
  endColor = '#0000ff',
  timeDilation = 2000
) {
  const interpolator = useMemo(
    () => interpolateHslLong(startColor, endColor),
    [startColor, endColor]
  );
  const requestId = useRef(null);
  const [color, setColor] = useState(null);
  // this boolean will be true when rendering, false otherwise
  const [running, setRunning] = useState(run);
  // these functions allow us to control the render loop
  const start = useCallback(() => setRunning(true), []);
  const stop = useCallback(() => setRunning(false), []);

  const animate = (time) => {
    setColor(interpolator(Math.abs(Math.sin(time / timeDilation))));

    // stop the animation loop if running is false
    if (running) {
      requestId.current = requestAnimationFrame(animate);
    }
  };

  useEffect(() => {
    // either start or stop the loop based on `running`
    if (running) {
      requestId.current = requestAnimationFrame(animate);
    } else {
      cancelAnimationFrame(requestId.current);
    }

    return () => cancelAnimationFrame(requestId.current);
  }, [running]);

  // return an object with the original color, and new start/stop functions
  return { color, start, stop };
}

Now it's a simple matter to use the new start/stop functions:

import useRainbow from 'hooks/useRainbow';

export default function Component() {
  // false means "do not start immediately"
  const { color, start, stop } = useRainbow(false);

  return (
    <p onMouseEnter={start} onMouseLeave={stop} style={{ color }}>
      Hello World
    </p>
  );
}

Demonstration 2

Now, the animation only runs when the mouse is over the control.

Hello World