How to Animate CSS Gradients Without Forcing Layout or Paint
CSS gradients cannot be transitioned directly—the browser snaps between values. Learn how @property enables smooth gradient animation by typing custom properties for interpolation.
Animating a CSS gradient fails out of the box. background-image does not interpolate between two gradient values. The browser snaps at the halfway point of a transition, treating each gradient as an opaque image and cross-fading them like two JPEGs. The fix is @property. It lets you register a custom property with a syntax descriptor like <color> or <length-percentage>, giving the engine a type to interpolate frame by frame. That registered custom property becomes the animated value inside a linear-gradient() that stays static in the stylesheet. The browser handles the smooth shift between stops without forcing layout or repainting the whole element. The practical result: a background that moves from deep blue to warm orange across two seconds, with the motion running on the compositor thread, not the main thread, and a fallback for browsers that never shipped @property.
What Actually Happens When You Transition background-image
Transitioning background-image between two gradient declarations does not animate. The CSS spec defines gradients as images, and images are not interpolable. They cannot be blended mathematically the way numbers or colors can. So the browser computes the initial value, computes the final value, and at the first frame of the transition it swaps one for the other. You get a hard cut, not a fade, and certainly not a drift of one color stop into its neighbor. The spec does allow a cross-fade, which some engines attempt, but that reads as a flash of transparency, not as a gradient whose hues slide.
The cost of trying is worse than the visual failure. Animating background-image triggers paint on every single frame because the engine has to rasterize the entire background region each time the value changes. Paint cost is qualitative: none, low, medium, high. Here it is high. The gradient covers the full element and the engine must recalculate every pixel of that surface. There is no way to promote that work to the compositor. The compositor only handles properties it can transform without asking the main thread for new pixels. transform and opacity are the canonical compositor-safe properties; background-image is the opposite.
Position Trick: The Pre-@property Workaround
A common workaround is to enlarge the background and animate its position. Declare background-size: 200% 200% and then transition background-position from 0% 0% to 100% 100%. That technique predates @property and it works because background-position is a <length-percentage> pair, which the engine can interpolate. The gradient itself stays static; only the viewport into that oversized gradient moves. It is compositor-safe in the sense that no new pixels are painted. The element’s layer already contains the full gradient, and shifting the position only moves the visible slice. This remains a legitimate fallback for older browsers, but it is limited to a single direction of movement and cannot change the colors or the stop positions themselves.
Registering Custom Properties with @property for Gradient Interpolation
The reason @property changes the game is the syntax descriptor. Without registration, a custom property (--color-stop, say) is an untyped token stream. The engine treats it as a string, and strings do not interpolate. Register it with syntax: "<color>" and give it an initial-value, and the property becomes typed. The engine can then compute intermediate values between two colors. The same applies to positions: syntax: "<length-percentage>" with initial-value: 0% makes a custom property that animates smoothly between percentage values.
The registration block must live at the top level of your CSS, outside any selector, and it must include both syntax and initial-value. Omitting initial-value is a common failure: the property is registered but not animatable because the engine has no starting point for the interpolation. The motion itself then does nothing, and the gradient snaps when the animation would have ended.
Sample: Moving a Center Color
Here is a complete, runnable sample that moves a gradient’s center color from red to blue:
@property --stop-color {
syntax: "<color>";
initial-value: red;
inherits: false;
}
.animated-gradient {
width: 300px;
height: 150px;
background: linear-gradient(to right, var(--stop-color), transparent);
animation: shift-color 2s ease-in-out infinite alternate;
will-change: background;
}
@keyframes shift-color {
to {
--stop-color: blue;
}
}
@media (prefers-reduced-motion: reduce) {
.animated-gradient {
animation: none;
}
}
Note the will-change: background declaration. That is the compositor-safe promotion: it tells the browser to move the element onto its own layer before the motion starts, so the compositor can handle the repaint without blocking the main thread. Without it, the animation still runs but the paint work happens on the main thread, which can jank on low-end devices. The prefers-reduced-motion media query wraps the animation in animation: none, honoring the user’s system-level setting for reduced motion, and the static background declaration remains as the default state.
Animating Gradient Colors with <color> Syntax
Moving a gradient’s color stops is the most common request, and @property with a <color> syntax handles it cleanly. Register one custom property per stop you want to animate, then reference those properties inside the gradient. The engine interpolates each registered property independently, so you can shift two stops at different rates in the same animation.
Sample: Two-Stop Pulse
Here is a sample that animates two color stops in opposite directions, creating a pulsing effect:
@property --start-color {
syntax: "<color>";
initial-value: #ff7e5f;
inherits: false;
}
@property --end-color {
syntax: "<color>";
initial-value: #feb47b;
inherits: false;
}
.pulse-gradient {
width: 100%;
height: 200px;
background: linear-gradient(to bottom, var(--start-color), var(--end-color));
animation: pulse 3s ease-in-out infinite alternate;
will-change: background;
}
@keyframes pulse {
from {
--start-color: #ff7e5f;
--end-color: #feb47b;
}
to {
--start-color: #6a11cb;
--end-color: #2575fc;
}
}
@media (prefers-reduced-motion: reduce) {
.pulse-gradient {
animation: none;
}
}
This works in Chromium, Firefox, and Safari in versions that support @property. Check caniuse for the current support matrix. For browsers that do not support @property, the registered properties are ignored, and the var() references fall back to the initial-value, which produces a static gradient. That is graceful degradation, but you may want an explicit fallback for older engines. Wrap the animated version in an @supports block that tests for registration, and provide a static gradient outside it:
@supports not ( (--test: registered) ) {
.pulse-gradient {
background: linear-gradient(to bottom, #ff7e5f, #feb47b);
animation: none;
}
}
The @supports check is not perfect. It tests for the custom property syntax, not for @property itself, but it is the closest CSS has to a feature query for this registration mechanism. The fallback ensures the page never shows a broken gradient on older iOS Safari versions locked to unsupported devices or on Android WebView instances that do not update.
Animating Gradient Positions with <length-percentage> Syntax
Color is only half of a gradient. The stop positions, where each color begins and ends, are equally animatable, but they need a different registration. Use syntax: "<length-percentage>" and an initial-value that is a valid length or percentage, such as 0% or 50%. Then animate that custom property, and the gradient’s stops slide across the element.
Sample: Hard-Stop Sweep
Here is a sample that moves a hard stop from left to right, creating the effect of a color sweeping across the background:
@property --stop-pos {
syntax: "<length-percentage>";
initial-value: 0%;
inherits: false;
}
.sweep-gradient {
width: 300px;
height: 100px;
background: linear-gradient(to right, navy var(--stop-pos), gold var(--stop-pos), transparent);
animation: sweep 4s ease-in-out infinite alternate;
will-change: background;
}
@keyframes sweep {
to {
--stop-pos: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.sweep-gradient {
animation: none;
}
}
The two var(--stop-pos) references in the gradient create a hard stop at that point, navy on one side, gold on the other, then transparent beyond. Animating --stop-pos from 0% to 100% moves that boundary across the element. The engine interpolates the custom property as a length-percentage, producing a smooth sweep. This is a distinct behavior from the background-position trick because it changes the geometry of the gradient itself, not just the viewport into a larger static image.
A conic-gradient works the same way. Register an angle or percentage custom property and animate it to spin the gradient’s starting point. The interpolation rules are identical, and the compositor handles the repaint as long as you include will-change and respect reduced motion.
Compositor-Safe Gradient Animation with will-change and Layers
The term “compositor-safe” describes whether the browser can animate a property without asking the main thread to recalculate layout or repaint pixels. transform and opacity are compositor-only because the compositor can apply them to an already-rasterized layer. background-image is not, because changing it requires new rasterization. Custom properties used inside a gradient sit in a middle ground: the engine can compute the interpolated value on the main thread, but if the resulting gradient is painted to the element’s layer, that paint happens on the main thread.
will-change: background changes this. It promotes the element to its own compositor layer before the motion starts, so the compositor can handle the repaint of that layer without blocking the main thread. The paint cost drops from high to low because the layer is isolated and the compositor can cache and reuse the painted content between frames. Layout cost is none. Gradients do not affect the element’s size or position in the document flow.
When Not to Use will-change
This is not a silver bullet. will-change is a hint, not a command, and the browser may ignore it if memory is constrained. It also has a cost: each promoted layer consumes memory, and too many layers can degrade performance. Use it only on elements that are actually animating, and remove it after the motion ends if the element is not going to animate again. The animation shorthand resets the property automatically when the animation completes, so in practice you declare will-change in the same rule as the animation and let it persist for the animation’s duration.
The failure case is a gradient that animates without will-change. The motion runs, but every frame triggers a paint on the main thread, which competes with JavaScript and layout work. On a page with multiple animated gradients, that contention causes dropped frames and visible jank. The fix is not to abandon the technique but to promote the layer and limit the number of simultaneously animating gradients to what the device can handle.
@property Gradient Animation: The Complete Recipe
Putting it all together, here is the pattern that works across supported browsers and degrades cleanly elsewhere. Register every custom property you animate with the correct syntax and a non-optional initial-value. Reference those properties inside a static background declaration. Write keyframes that animate only the custom property values. Add will-change: background to promote the element. Wrap the animation in a prefers-reduced-motion query that sets animation: none. Provide a static fallback inside @supports not for engines without @property.
The order matters. The @property blocks must appear before any rule that uses them, or the engine may treat the property as untyped and refuse to interpolate. The initial-value is not a suggestion; it is the computed value the animation starts from. The inherits descriptor controls whether the property cascades to children. For gradient stops, inherits: false is usually correct because you want each element to have its own independent animation.
Full Sample: Radial Glow
Here is a full sample that combines color and position animation in a radial-gradient:
@property --glow-color {
syntax: "<color>";
initial-value: rgba(255, 0, 0, 0.8);
inherits: false;
}
@property --glow-size {
syntax: "<length-percentage>";
initial-value: 10%;
inherits: false;
}
.glow {
width: 200px;
height: 200px;
background: radial-gradient(circle at center, var(--glow-color) 0%, transparent var(--glow-size));
animation: glow-pulse 2s ease-in-out infinite alternate;
will-change: background;
}
@keyframes glow-pulse {
from {
--glow-color: rgba(255, 0, 0, 0.8);
--glow-size: 10%;
}
to {
--glow-color: rgba(0, 0, 255, 0.8);
--glow-size: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.glow {
animation: none;
}
}
@supports not ( (--test: registered) ) {
.glow {
background: radial-gradient(circle at center, red 0%, transparent 10%);
animation: none;
}
}
The @supports fallback uses the same initial values as the registered properties, so the static state matches the animation’s starting frame. That way, a user on an old browser sees a static red glow instead of a broken or invisible background.
Fallbacks and Failure Modes for Animated Gradients
Two mistakes break the technique. The first is animating background-image directly between two gradient declarations. Even if you register custom properties, writing background: linear-gradient(...) in a keyframe and another background: linear-gradient(...) in a later keyframe is still an image-to-image swap, not an interpolation. The registered properties must appear inside a single static gradient, and only the custom property values change in the keyframes.
The second mistake is forgetting the initial-value. A registered property without an initial-value is not animatable because the engine has no starting point. The animation silently fails, and the gradient stays at whatever value the var() fallback provides, which may be nothing. Always set initial-value to a valid value for the syntax you declare.
Understanding the @supports Gate
Another failure mode is the @supports query itself. @supports not ( (--test: registered) ) tests whether the custom property syntax is recognized, not whether @property is supported. In practice, browsers that support @property also support custom property declarations, so the query works as a gate. But it is not perfect, and the real browser support gap is covered by the static fallback. The precise share of users on unsupported devices is not reliably measured by any single public survey, so ship the fallback and test it.
If you need to support browsers that predate @property entirely and cannot use the fallback, the background-position technique with an oversized gradient is the oldest reliable method. It has a paint cost of none, because no new pixels are painted, only the position of the existing layer. Reach for it when @property is unavailable and you still want a moving gradient. It is limited to shifting the viewport, but for a simple directional sweep it is a solid alternative.
When the Gradient Animation Fails: Debugging the Silent Snap
If your animation snaps instead of transitioning, check whether the custom property is actually registered. Open the browser’s computed styles panel, not the styles panel, which shows authored declarations, and look for the property under the element’s computed values. If it shows the initial-value rather than the animated value, the registration is missing or the syntax is wrong. A common typo is syntax: "<color> " with a trailing space, which makes the declaration invalid.
Check the Starting Point
The second check is the initial-value. If you registered the property but the motion does nothing, verify that the initial-value matches the starting value in your keyframes. The animation interpolates from the computed value to the keyframe’s value, and if the computed value is the same as the keyframe’s from, the animation has nowhere to go.
Check Reduced Motion
The third check is the prefers-reduced-motion query. If your media query sets animation: none and the user has reduced motion enabled at the system level, the animation will not run. That is correct behavior, but it can look like a bug. Test with reduced motion disabled to confirm the animation works, then trust the query to do its job.
If the animation runs but stutters, the issue is main-thread paint. Add will-change: background and reduce the number of animated gradients on the page. If the stutter persists, the element may be too large. Animating a gradient on a full-screen element every frame is expensive on low-end devices. Consider animating a smaller overlay instead.
Frequently Asked Questions
Why does transitioning background-image not work for gradients?
Gradients are images, and the CSS spec does not define interpolation between two images. The browser treats a transition from one gradient to another as a cross-fade, which in most engines is a hard snap at the midpoint. Only registered custom properties with a typed syntax allow the engine to compute intermediate colors and positions.
What does @property actually do for gradient animation?
@property registers a custom property with a syntax descriptor, like <color> or <length-percentage>, and an initial-value. This typing tells the engine the property can be interpolated, so keyframes can animate it. Without registration, the property is an untyped token stream and cannot transition or animate.
Is will-change: background always necessary?
No, but it is recommended. Without it, the animation runs on the main thread and triggers paint on every frame. will-change promotes the element to its own compositor layer, letting the compositor handle repaints. Use it only on elements that actually animate, and remove it after the animation completes.
What is the fallback for browsers without @property?
Use the background-position technique with an oversized gradient, or provide a static gradient inside an @supports not block. The static fallback should match the animation’s starting state so the page looks correct even when no motion runs.
What to Do Next: Build One Gradient and Measure It
Take the first code sample from this article, the one that animates --stop-color from red to blue, and run it in a browser with the performance panel open. Watch the frame timeline while the motion runs. You will see whether the paint work happens on the compositor thread or the main thread by checking the green and yellow bars. If the yellow bars appear, will-change is not working. Investigate why: a missing declaration, or an element that is too large. If the green bars dominate, the animation is compositor-safe. Reuse the pattern for any gradient animation on your site.
Then test the fallback. Disable @property support in your browser’s developer tools (most engines allow you to toggle feature flags) and confirm the static gradient renders. That is the experience a user on an unsupported device gets. It must not be broken.
Finally, write the prefers-reduced-motion query first, before you add the animation. That forces you to design for accessibility from the start, not as an afterthought. A user who has reduced motion enabled should see the static gradient, not a frozen animation frame.