Why Animating a CSS Gradient Tanks Performance (and the Fix)
A gradient that shifts or pulses looks great in a design mockup, and the first instinct is to animate it the direct way: keyframe the background-position, or the background-size, or a custom property feeding the gradient's angle. It works. It also quietly turns every frame of that animation into a full repaint, which is why the same effect that's silky on your laptop chugs and drops frames the moment someone opens the page on an actual phone.
The fix isn't a different gradient — it's animating a completely different property to get the same visual result.
Why the browser can't fast-path this one
Modern browsers can animate `transform` and `opacity` on the compositor thread, skipped-layout, skipped-paint, running on the GPU independent of whatever the main thread is doing. Almost nothing else gets that treatment. `background-position`, `background-size`, and custom properties consumed inside a `linear-gradient()` or `radial-gradient()` all require the browser to recompute the gradient and repaint the element on the main thread, every single frame, for the full duration of the animation.
On a desktop with headroom to spare, a repaint-every-frame animation can still hit 60fps and look fine. On a mid-range phone under real conditions — other tabs open, background processes running, thermal throttling — that same animation is exactly the kind of thing Lighthouse flags as a 'non-composited animation,' and exactly the kind of thing that shows up as visible jank rather than a smooth motion.
The fix: move the gradient, don't repaint it
The standard trick is to render the gradient once, at a size larger than its visible container, and animate `transform: translate()` on that oversized layer instead of animating any property of the gradient itself. The gradient itself never changes — same colors, same stops, computed once — only its position shifts, and a `translate()` is compositor-only, so the browser can slide it around at 60fps without touching layout or paint at all.
Concretely: give the gradient layer roughly double the container's width (or height, depending on the direction of motion), position it absolutely inside a container with `overflow: hidden`, and animate `transform: translateX(...)` back and forth or in a loop. The visible effect — a gradient that appears to drift or shimmer — is identical to animating `background-position`, but the browser is doing dramatically less work to produce it.
When there's no clean equivalent
Some gradient animations genuinely don't have a transform-only equivalent — animating the actual color stops of a gradient over time (a true color crossfade, not a moving position) is one of them, since there's no way to represent 'these five colors are becoming these five different colors' as a translate or scale. In that case, the honest options are to crossfade two overlapping gradient layers by animating `opacity` (which is compositor-friendly) instead of animating the colors directly, or to accept that the effect needs to be simplified — a static gradient, or a much shorter animation that plays once on load rather than looping indefinitely and repainting for as long as the page is open.
The rule of thumb that generalizes past gradients: if you're reaching for a keyframe on anything other than `transform` or `opacity`, stop and look for a version of the same effect built from an oversized layer that only translates or fades, rather than one that repaints.
Build the gradient first
Use our CSS Gradient tool to design the linear, radial, or conic gradient itself — get the colors and stops right first, then apply the transform-only motion technique above once you're happy with the static version. It generates ready-to-paste CSS entirely in your browser.