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
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.