Continuously Animating CSS Variables with React and chroma-js
Overview
The goal of this post is to demonstrate how to create a React component that transitions between two color states over a set duration. Once the transition completes, it randomly selects a new color to transition to, resulting in an ongoing sequence of color changes. This technique employs:
- requestAnimationFrame to schedule animation frames.
- chroma-js for smoothly interpolating between color values.
- CSS variables to apply the active color throughout your application’s styles.
By utilizing CSS variables, you can keep the logic for color updates in one place while allowing any element in your UI to respond to those updates in real time.
Why Continuous Color Transitions?
Continuous color animations can serve multiple purposes:
- Visual Interest: Animating colors can make an application feel more dynamic or playful.
- Theming: You can demonstrate different themes (e.g., day/night modes) or seasonal themes (holidays, special events) by cycling through color palettes.
- User Engagement: Subtle transitions can keep users’ attention longer. However, overly frequent or jarring transitions can be distracting, so careful tuning of speed and brightness is important.
- Ambient Feedback: Color changes can cue users that new content is loading or changing, acting as gentle visual feedback.
Code Example
The code can be viewed here: Github
Below is a React component called VariableTransition that continuously animates between different colors. I assume you have:
- A list or array of colors, colorsArr, which stores possible color indices.
- A function, getColorMap(index), returning a map of variable keys (like "primary", "secondary") to hex color values.
- An initial color index, PRIMARY_INDEX, to serve as a starting point.
"use client"; import chroma from "chroma-js"; import { useState, useEffect, useRef } from "react"; // Example color array and getColorMap function import { getColorMap, PRIMARY_INDEX, colorsArr } from "@/colors"; const DURATION = 2500; // Applies a CSS custom property to the document root const setRootVar = (varName: string, hexValue: string) => { document.documentElement.style.setProperty(`--${varName}`, hexValue); } // Returns a new random index that is not the 'except' index const getRandomIndexExcept = (length: number, except: number) => { let randomIndex = except; while (randomIndex === except) { randomIndex = Math.floor(Math.random() * length); } return randomIndex; } const VariableTransition = () => { // Timestamp reference to control animation progress const startTime = useRef<number | null>(null); // Reference to the requestAnimationFrame ID const animationFrame = useRef<number | null>(null); // Starting color index const [activeColorIndex, setActiveColorIndex] = useState(PRIMARY_INDEX); // Target color index const [nextColorIndex, setNextColorIndex] = useState(() => getRandomIndexExcept(colorsArr.length, PRIMARY_INDEX) ); useEffect(() => { const animate = (timestamp: number) => { if (!startTime.current) { startTime.current = timestamp; } const elapsed = timestamp - startTime.current; const ratio = Math.min(elapsed / DURATION, 1); const oldColors = getColorMap(activeColorIndex); const newColors = getColorMap(nextColorIndex); // Interpolate each variable from old to new based on ratio for (const varKey of Object.keys(oldColors)) { const oldValue = oldColors[varKey]; const newValue = newColors[varKey]; if (chroma.valid(oldValue) && chroma.valid(newValue)) { const interpolated = chroma.mix(oldValue, newValue, ratio).hex(); setRootVar(varKey, interpolated); } else { // If invalid, default to the old value setRootVar(varKey, oldValue); } } // If the animation is still in progress, request another frame if (ratio < 1) { animationFrame.current = requestAnimationFrame(animate); } else { // Reset timing and choose next colors startTime.current = null; setActiveColorIndex(nextColorIndex); const randomIndex = getRandomIndexExcept(colorsArr.length, nextColorIndex); setNextColorIndex(randomIndex); } } // Start the animation animationFrame.current = requestAnimationFrame(animate); // Cleanup on component unmount return () => { if (animationFrame.current) { cancelAnimationFrame(animationFrame.current); } }; }, [activeColorIndex, nextColorIndex]); return null; } export default VariableTransition;
How It Works
- activeColorIndex and nextColorIndex track the “from” and “to” colors.
- requestAnimationFrame calculates ratio (ranging from 0 to 1) as the transition progresses, giving smooth frame updates.
- chroma-js is used to mix colors. As ratio goes from 0 to 1, each color variable interpolates from its old value to its new value.
- CSS variables are updated continuously within the animation callback, so any part of the app that references them will shift colors in real time.
- Once the transition completes, the code sets activeColorIndex to the old nextColorIndex and chooses a new random index for nextColorIndex. The process then repeats, resulting in a continuous cycle.
Additional Considerations
- Performance: Continuous animations can increase CPU/GPU usage, especially if the transitions involve complex elements or run at high frame rates. Fine-tune the DURATION and consider throttling or pausing animations on resource-constrained devices if necessary.
- Accessibility: Rapid, flashy transitions can be disorienting for some users. Consider a slower fade or an option to turn off animations.
- Color Choices: Ensure your color array has enough contrast if text or interactive elements rely on these colors. You may want to specify sets of complementary color pairs.
- Use Cases: Subtle brand theming, cyclical seasonal changes, or guiding users’ attention during background tasks.
By applying these techniques, you can add a continuously cycling color scheme to your application without manually handling each transition. This can help a design feel more dynamic, and it’s all handled by a straightforward React component with minimal overhead. If you decide to extend this, consider advanced color models (lab or lch) within chroma-js for smoother transitions that respect human color perception.
Authors Note:
At this point in the article, I'd like to point out that there are a few ways to achieve a similar effect. It's important to note that my solution is GPU intensive since chroma-js is recalculating hex values and injecting that into the DOM every second, indefinitely. You could alternatively just put the component on an interval loop of some duration, set the css transition of all colors (tailwind's "transition-colors" class) and also set the transition duration to be similar to the interval loop duration. That would take the load off of the GPU and put it on the CPU making the app less likely to crash long term, but then you'd have to have a deep dive into your CSS transition properties and ensure that no clashes happen during the waterfall of styling.
For example, imagine you set this within your tailwind config:
plugin(({ addBase, theme }) => { addBase({ "*": { "@apply transition-colors duration-30s": "" } }) })
Great, now all your components have that transition effect built in. But say you want to further style an archor tag or some other hover effect on some other element. When the variable transition happens it would be superseded by the subsequent styling and the color transition wouldn't align to the rest of the application. So then you decide to use opacity as a differentiator for hover and other interactive effects, well then you'd run into accessibility issues with color contrast and legibility. That's a headache that I'd like to avoid if and when possible.
Everything has drawbacks when it comes to programming; there's no such thing as perfect code and sometimes you have to weigh the pros with the cons and sit with the decisions and justifications that make sense to you.