The pure CSS version
The standard trick is to make the gradient much wider than the viewport and animate background-position. Nothing else in CSS will interpolate gradient colour stops, so this is the technique whether or not it is the one you wanted:
.animated-gradient {
background: linear-gradient(120deg, #FF5772, #4CB4BB, #FFC600, #8B6AE6);
background-size: 400% 400%;
animation: gradient-drift 18s ease infinite;
}
@keyframes gradient-drift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@media (prefers-reduced-motion: reduce) {
.animated-gradient { animation: none; }
}
What that gets you, and what it does not
It is about ten lines, needs no JavaScript, works everywhere, and costs essentially nothing. For a lot of sites that is the correct answer and you can stop reading here.
The limitation is structural rather than a matter of effort. You are sliding a fixed image behind a window, so the motion is always linear and always loops visibly. There is no depth, because there is no surface being lit. Colours cannot pool or fold into each other, because linear-gradient() only interpolates along one axis. And on a large viewport, animating background-position on a huge background image is one of the more expensive things you can ask a compositor to do repeatedly.
A slightly better-behaved variant animates a transform on an oversized pseudo-element instead, which keeps the work on the GPU:
.gradient-host { position: relative; overflow: hidden; isolation: isolate; }
.gradient-host::before {
content: "";
position: absolute;
inset: -50%;
z-index: -1;
background: conic-gradient(from 0deg, #FF5772, #4CB4BB, #FFC600, #8B6AE6, #FF5772);
filter: blur(80px);
animation: gradient-spin 24s linear infinite;
will-change: transform;
}
@keyframes gradient-spin { to { transform: rotate(1turn); } }
When to use WebGL instead
If you need the colours to actually move through each other rather than past the window — folding, pooling, catching light — CSS cannot express that. It has no concept of a surface with geometry.
Neat renders a displaced 3D plane on the GPU and blends up to six colours across it in a fragment shader. That is where the depth comes from. The cost is roughly 17 KB gzipped and a <canvas> element:
import { NeatGradient } from "@firecms/neat";
const gradient = new NeatGradient({
ref: document.getElementById("gradient"),
colors: [
{ color: "#FF5772", enabled: true },
{ color: "#4CB4BB", enabled: true },
{ color: "#FFC600", enabled: true },
{ color: "#8B6AE6", enabled: true },
],
speed: 2.5,
waveAmplitude: 5,
colorSaturation: 7,
});
// Respect the user's motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
gradient.speed = 0;
}
A note on reduced motion
Whichever route you take, honour prefers-reduced-motion. A full-viewport animated background is exactly the category of motion that setting exists for, and both examples above show how to disable it. A still gradient is not a degraded experience — several of the presets here are close to motionless by design.
Questions
How do I animate a CSS gradient?
Make the gradient larger than its container with background-size, then animate background-position in a keyframe loop. CSS cannot interpolate gradient colour stops directly, so moving the gradient behind a fixed window is the available technique.
Is an animated CSS gradient bad for performance?
Animating background-position on a full-viewport element forces repeated compositing work and can cost noticeably on low-end devices. Animating a transform on an oversized blurred pseudo-element is cheaper because it stays on the GPU.
Can CSS produce a mesh gradient?
Approximately, by layering several radial-gradient() backgrounds at different positions. It works for a static flat mesh but cannot animate smoothly or produce depth shading.
Does Neat output CSS?
No. Neat renders to a canvas with WebGL, so the export is a JavaScript config object, a PNG, or a video file. If you specifically need a CSS string, use the copy-paste code on this page.