The Hue Shift Problem

Building a particle effect where each particle starts on a random color and shifts hue as it fades looks straightforward — but there's a hidden CSS gotcha hiding in the interpolation math.

Generating Cohesive Random Colors

The obvious starting point is generating random RGB values:

// Do this for every particle:
const red = Math.round(Math.random() * 255);
const green = Math.round(Math.random() * 255);
const blue = Math.round(Math.random() * 255);

particle.style.backgroundColor =
  `rgb(${red} ${green} ${blue})`;

That gives you access to the full 16.7 million possible colors, but the result is total chaos. What if you want all the particles to share a general feel, like pastel or neon? With rgb(), there's no clean way to do that. Switching to hsl() makes it trivial:

const randomHue = Math.round(
  Math.random() * 359
);
particle.style.backgroundColor =
  `hsl(${randomHue}deg 100% 80%)`;

Pick a random hue, but lock saturation and lightness to fixed values. That gives you a coherent set of pastel-ish tones. Randomness can still be clumpy — some hues may repeat while others never appear — but that won't matter if you're shifting colors continuously anyway.

The Interpolation Trap

For each particle, you want it to transition between two colors on opposite sides of the color wheel. Here's the first attempt:

const fromHue = Math.round(
  Math.random() * 359
);
const toHue = fromHue + 180;

particle.style.setProperty(
  '--from-color',
  `hsl(${fromHue}deg 100% 80%)`
);
particle.style.setProperty(
  '--to-color',
  `hsl(${toHue}deg 100% 80%)`
);
/* And then, in the CSS: */
@keyframes colorShift {
  from {
    background: var(--from-color);
  }
}

.particle {
  background-color: var(--to-color);
  animation: colorShift 1500ms linear;
}

Animating two hsl() colors with a linear timing function seems right: each intermediate color appears at equal intervals. But the actual result looks washed out. Slowing it down reveals why: at the halfway point, all the particles become grey.

A pure hue shift from red to teal should preserve saturation and lightness. Instead, the browser interpolates background-color in the RGB color space, regardless of how you specify the colors. In RGB, you have three independent channels, and the math happens on each separately. Transitioning from red (255, 77, 77) to teal means decreasing the red channel while increasing green and blue. All three converge toward the same value in the middle, producing grey.

The bias becomes even clearer with a full 360° rotation. Trying to interpolate from hsl(0deg 100% 65%) to hsl(360deg 100% 65%) does nothing at all. Both values resolve to the exact same rgb() color, so the browser sees no difference and doesn't animate anything.

Filter-Based Hue Rotation

The CSS filter property solves both problems. The hue-rotate() filter shifts the hue of an element directly, without the RGB interpolation detour.

On the JavaScript side, you can generate a single random HSL color:

const randomHue = Math.round(
  Math.random() * 359
);

particle.style.backgroundColor =
  `hsl(${randomHue}deg 100% 80%)`;

And in CSS, a keyframe animation handles the shift:

@keyframes hueRotate {
  to {
    filter: hue-rotate(720deg);
  }
}

.particle {
  animation: hueRotate 1000ms;
}

The particles can now rotate through two full turns of the color wheel without ever hitting grey. It's not quite identical to animating the hue component of an hsl() color — hue-rotate() produces slightly darker intermediate values — but the effect is correct, and it performs well.

For practical use, two full rotations is likely excessive. A range of 180° to 540° reads more naturally.

Twinkling on Fade

A final detail that makes particles feel alive is twinkling as they fade out, rather than dropping opacity linearly. Small random variations per particle, via custom properties like --twinkle-duration and --twinkle-amount, prevent the flicker from locking into a synchronized pattern.

The Takeaway

When animating between two colors specified in any format other than RGB, remember that browsers still perform the interpolation in RGB space. If your transition needs to preserve saturation, or spin past 180°, skip background-color animation and use filter: hue-rotate() instead.