A Gradient That Actually Flows
Rainbow gradients are making a comeback. The problem? Animating them has always been a kludge. My first attempt involved creating an oversized gradient and translating it vertically inside an overflow: hidden container, resetting the position once it reached the bottom. It worked, sort of, but the loop reset was visible on slower devices, and the motion felt mechanical rather than organic.
The breakthrough came from abandoning linear motion altogether. Instead of moving a static gradient, I built one that never moves — it just changes color over time.
The Radial-Gradient Trick
The core idea is a radial-gradient anchored to the top-left corner, with three colors bleeding outward. Those three colors are always adjacent in a fixed 10-color rainbow palette. On each animation tick, the colors shift one position down the palette — so the color at position C3 is always one palette entry behind C2.
Nothing physically moves. Each point in the gradient simply inherits the color of the point next to it. The result is a cascading wash of color, similar to venue lighting that chases across a marquee:
3
The Missing Interpolation Piece
The plan had four parts: define a 10-color palette, create a gradient holding a moving 3-color window, shift that window every second, and tween the colors smoothly between shifts.
The last step is where it gets tricky. CSS transition doesn't interpolate between two background values:
.gradient {
background: radial-gradient(...);
/* 🙅♀️ Doesn't work */
transition: background 1000ms;
}
Doing it all in JavaScript — splitting every color change into ~60 steps within a requestAnimationFrame loop — felt heavy and ran the risk of choppiness on the main thread. The cleaner path was to let CSS handle the interpolation itself.
Custom Properties Are Real Properties
CSS variables aren't just static values resolved at parse time like SASS variables. They live in the CSSOM at runtime, which means they participate in the cascade and can be updated dynamically. That makes all the difference here.
Setting a custom property inside a gradient is straightforward:
.gradient {
/*
Variables are defined right along style declarations.
They're indicated by the double-hyphen prefix.
*/
--color-1: deepskyblue;
--color-2: navy;
/*
You can access variables using the 'var()' function:
*/
background: linear-gradient(170deg, var(--color-1), var(--color-2));
}
In a React component, you can apply those same variables through inline styles:
<div
style={{
'--color-1': 'deepskyblue',
'--color-2': 'navy',
background: `
linear-gradient(
170deg,
var(--color-1),
var(--color-2) 80%
)
`,
// Unrelated styles:
color: 'white',
textAlign: 'center',
padding: 30,
borderRadius: 12,
}}
>
Hello World
</div>
But variables alone don't solve the problem. You still can't apply transition to the background property itself.
CSS Houdini Unlocks the Real Magic
CSS Houdini is a broad effort to give developers direct access to the internal machinery of the CSS engine. Rather than waiting for features like masonry layout to be implemented natively, Houdini lets you build them yourself and hook them directly into CSS's own mechanisms.
The specific piece that matters here is animated custom properties. Variables in CSS are called "custom properties" for good reason: they behave like the browser's own properties — display, transform, color — not like preprocessor variables.
.gradient {
/* Create a new custom property, and give it a value: */
--color: navy;
/* Access that value using the `var` function: */
background-color: var(--color);
border: 2px dashed var(--color);
}
The payoff: you can apply transitions directly to custom properties:
.gradient {
--magic-rainbow-color-0: hsl(0deg, 96%, 55%);
--magic-rainbow-color-1: hsl(25deg, 100%, 50%);
--magic-rainbow-color-2: hsl(40deg, 100%, 50%);
background: linear-gradient(
170deg,
var(--magic-rainbow-color-0),
var(--magic-rainbow-color-1),
var(--magic-rainbow-color-2)
);
/* 🤯 */
transition:
--magic-rainbow-color-0 1000ms linear,
--magic-rainbow-color-1 1000ms linear,
--magic-rainbow-color-2 1000ms linear;
}
This doesn't animate the background itself. It animates the custom property, and the var() reference inside the gradient reacts to each change, triggering a repaint on every frame of the tween. The interpolation happens in the browser's native CSS engine, not in JavaScript.
Registering the Property Type
There's one prerequisite. The browser needs to know what kind of value the custom property holds — a color, a length, an angle — so it knows how to interpolate from one value to another. Registering happens in JavaScript:
CSS.registerProperty({
// The name of our property, should match what we use in our CSS:
name: '--color-1',
// How we want to interpolate that value, when it changes:
syntax: '<color>',
// Whether it should inherit its value from its ancestors
// (like `font-size` does) or not (like `position` doesn't)
inherits: false,
initialValue: 'hsl(0deg, 96%, 55%)',
});
A Vanilla JS Implementation
Here's the complete setup in raw JavaScript, without any framework dependencies:
const rainbowColors = [
'hsl(1deg, 100%, 55%)', // red
'hsl(25deg, 100%, 50%)', // orange
'hsl(40deg, 100%, 50%)', // yellow
'hsl(130deg, 100%, 40%)', // green
'hsl(230deg, 100%, 45%)', // blue
'hsl(240deg, 100%, 45%)', // indigo
'hsl(260deg, 100%, 55%)', // violet
];
const paletteSize = rainbowColors.length;
// Number of milliseconds for each update
const intervalDelay = 1000;
const colorNames = [
'--magic-rainbow-color-0',
'--magic-rainbow-color-1',
'--magic-rainbow-color-2',
];
// Register properties
colorNames.forEach((name, index) => {
CSS.registerProperty({
name,
syntax: '<color>',
inherits: false,
initialValue: rainbowColors[index],
});
});
const buttonElem = document.querySelector('#rainbow-button');
let cycleIndex = 0;
window.setInterval(() => {
// Shift every color up by one position.
//
// `% paletteSize` is a handy trick to ensure
// that values "wrap around"; if we've exceeded
// the number of items in the array, it loops
// back to 0.
const nextColors = [
rainbowColors[(cycleIndex + 1) % paletteSize],
rainbowColors[(cycleIndex + 2) % paletteSize],
rainbowColors[(cycleIndex + 3) % paletteSize],
];
// Apply these new colors, update the DOM.
colorNames.forEach((name, index) => {
buttonElem.style.setProperty(name, nextColors[index]);
});
// increment the cycle count, so that we advance
// the colors in the next loop.
cycleIndex++;
}, intervalDelay);
Packaging It With React
The hook's public API was designed by first writing the component that consumes it — consumer-driven development. The component decides what to render; the hook doesn't care where the colors came from or how often they update.
import useRainbow from './useRainbow.hook';
const MagicRainbowButton = ({ children, intervalDelay = 1000 }) => {
// The hook should take 1 argument, `intervalDelay`.
// it should return an object in this shape:
/*
{
'--magic-rainbow-color-0': hsl(...),
'--magic-rainbow-color-1': hsl(...),
'--magic-rainbow-color-2': hsl(...),
}
*/
const colors = useRainbow({ intervalDelay });
const colorKeys = Object.keys(colors);
return (
<ButtonElem
style={{
// Spread the colors to define them as custom properties
// on this element
...colors,
// Use the keys to set the same transition on all props.
transition: `
${colorKeys[0]} ${transitionDelay}ms linear,
${colorKeys[1]} ${transitionDelay}ms linear,
${colorKeys[2]} ${transitionDelay}ms linear
`,
// Use those property values in our gradient.
// Values go from 2 to 0 so that colors radiate
// outwards from the top-left circle, not inwards.
background: `
radial-gradient(
circle at top left,
var(${colorKeys[2]}),
var(${colorKeys[1]}),
var(${colorKeys[0]})
)
`,
}}
>
{children}
</ButtonElem>
);
};
The initial implementation looks like this:
const rainbowColors = [
/* colors here */
];
const paletteSize = rainbowColors.length;
const useRainbow = ({ intervalDelay = 2000 }) => {
// On mount, register all of our custom properties
React.useEffect(() => {
for (let i = 0; i < 3; i++) {
try {
CSS.registerProperty({
name: `--magic-rainbow-color-${i}`,
initialValue: rainbowColors[i],
syntax: '<color>',
inherits: false,
});
} catch (err) {
console.log(err);
}
}
}, []);
// Get an ever-incrementing number from another custom hook*
const intervalCount = useIncrementingNumber(intervalDelay);
// Using that interval count, derive each current color value
return {
'--magic-rainbow-color-0': rainbowColors[(intervalCount + 1) % paletteSize],
'--magic-rainbow-color-1': rainbowColors[(intervalCount + 2) % paletteSize],
'--magic-rainbow-color-2': rainbowColors[(intervalCount + 3) % paletteSize],
};
};
export default useRainbow;
The hook tracks a single piece of state: the current interval count, since the colors themselves are derived data. In the 5th cycle, for example, the colors are simply the 5th, 6th, and 7th entries of the static rainbow palette.
useIncrementingNumber powers the cycle counter, pumping out a fresh count on a fixed interval, based on Dan Abramov's declarative setInterval pattern.
There is one lingering quirk: the hook registers global CSS custom properties from within an instanced component. Registering globally from a component instance is a smell, and it's worth tackling before relying on this in production. Still, as a proof of concept, it's hard to beat — Houdini opens up interpolation for custom properties in a way that makes previously awkward animations feel effortless.
From demo to something shippable
The naive implementation has one significant flaw: it relies on a global registry. Registering custom properties via CSS.registerProperty happens in a shared namespace, which means rendering two instances of the same component on one page will trigger an InvalidModificationError. A simple fix is to generate a unique name per instance with useId, sidestepping the clash entirely:
const useRainbow = ({ windowSize = 3, intervalDelay = 2000 }) => {
const uniqueId = React.useId();
React.useEffect(() => {
for (let i = 0; i < 3; i++) {
try {
CSS.registerProperty({
name: `--magic-rainbow-color-${uniqueId}-${index}`,
initialValue: rainbowColors[i],
syntax: '<color>',
inherits: false,
});
} catch (err) {
console.log(err);
}
}
}, []);
// The rest omitted. ✂️
};
That small degree of randomness is enough to keep instances isolated, even if the underlying API is still global.
Handling browsers that lag behind
Support for CSS.registerProperty is now universal among major browsers, but that wasn't always the case. A pragmatic fallback is to gracefully exit early when the API isn't present. Doing so means older clients see the static gradient but skip the animation—a reasonable tradeoff that avoids breaking layout or popping in a plain color:
const useRainbow = ({ windowSize = 3, intervalDelay = 2000 }) => {
const uniqueId = React.useId();
React.useEffect(() => {
if (!window.CSS || !window.CSS.registerProperty) {
return;
}
// The rest omitted. ✂️
}, []);
};
Performance and accessibility checks
Animation work is fastest when limited to opacity and transform, because those can be forwarded directly to the GPU. Animating custom properties does require a repaint per frame, though in profiling with a 6x CPU throttle, the paint work amounts to roughly 0.3 ms per frame—only 2% of the allotted budget for 60fps with no layout step involved.
Motion sensitivities are worth respecting too. A static gradient should be the default, and the animated version should only kick in when a user hasn't requested reduced motion. Structuring it this way also covers browsers that don't support prefers-reduced-motion at all, since those users will also get the static version rather than the more demanding animation:
.gradient {
/* Fallback background, for folks who wish to reduce motion */
background: linear-gradient(
170deg,
hsl(0deg, 96%, 55%),
hsl(25deg, 100%, 50%)
);
}
@media (prefers-reduced-motion: no-preference) {
.gradient {
--magic-rainbow-color-0: hsl(0deg, 96%, 55%);
--magic-rainbow-color-1: hsl(25deg, 100%, 50%);
--magic-rainbow-color-2: hsl(40deg, 100%, 50%);
background: linear-gradient(
170deg,
var(--magic-rainbow-color-0),
var(--magic-rainbow-color-1),
var(--magic-rainbow-color-2)
);
transition:
--magic-rainbow-color-0 1000ms linear,
--magic-rainbow-color-1 1000ms linear,
--magic-rainbow-color-2 1000ms linear;
}
}
Contrast is the other accessibility consideration. Adding a subtle text shadow and darkening the end of the spectrum helps keep the label readable even when the animated hue runs bright.
Take a look at an earlier version of the full component source for a working example of the finished button.



