Animating CSS Gradients: Smooth Colour Motion Without Jumps
A gradient that shifts hue on hover, a button whose sheen sweeps across it, a hero whose colours slowly drift — gradients are among the most requested animations and among the most commonly broken. Writing transition: background 0.5s between two gradients does nothing useful: the background snaps from one to the other at the end of the half-second. The reason is that background-image is not interpolable, and a gradient is an image. The narrow problem this page solves is animating gradients anyway, by animating the things that are interpolable: registered colour and position properties, the background's position and size, and hue angles in a perceptual colour space. It belongs to Color & Theme Transitions in the CSS-Only Micro-Interactions & Animations guide.
Why gradients refuse to transition
Interpolation needs a numeric path from the start value to the end value. Two colours have one: each channel moves linearly. Two lengths have one. Two gradient images do not in general — they may have different numbers of stops, different types (linear, radial, conic), different directions — so the specification treats background-image as a discrete property: the value flips at the end of a transition or halfway through an animation.
The way out is decomposition. A gradient is built from values that are interpolable on their own: colours, stop positions, an angle. If those values live in custom properties, and the custom properties are registered with a type, the browser can interpolate the properties and rebuild the gradient from them each frame. The image never interpolates; its inputs do.
The complete implementation
The page shows three techniques side by side: a hover that shifts both gradient colours through registered properties, a sheen that sweeps across a button by moving an oversized background, and a slowly rotating hue using oklch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animating gradients</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 1.5rem; display: grid; gap: 1.25rem; justify-items: start; }
/* 1. Colour shift on hover via registered colour properties. */
@property --from { syntax: "<color>"; inherits: false; initial-value: #6366f1; }
@property --to { syntax: "<color>"; inherits: false; initial-value: #22d3ee; }
.tile {
width: 16rem;
height: 6rem;
border-radius: 12px;
background: linear-gradient(135deg in oklch, var(--from), var(--to));
transition: --from 400ms ease, --to 400ms ease;
}
.tile:hover,
.tile:focus-visible {
--from: #ec4899;
--to: #f59e0b;
}
.tile:focus-visible { outline: 2px solid #0f172a; outline-offset: 3px; }
/* 2. Sheen sweep: a gradient twice as wide as the button, moved across it.
Only background-position animates, so the gradient is not rebuilt. */
.sheen {
padding: 0.7rem 1.4rem;
border: 0;
border-radius: 8px;
color: #ffffff;
font: inherit;
background:
linear-gradient(110deg, transparent 35%, rgb(255 255 255 / 0.35) 50%, transparent 65%)
0 0 / 250% 100% no-repeat,
#1d4ed8;
background-position: 100% 0, 0 0;
transition: background-position 700ms ease;
}
.sheen:hover,
.sheen:focus-visible {
background-position: 0% 0, 0 0;
}
.sheen:focus-visible { outline: 2px solid #0f172a; outline-offset: 3px; }
/* 3. Slow hue rotation: one registered angle drives both stops. */
@property --hue { syntax: "<angle>"; inherits: false; initial-value: 250deg; }
.aurora {
width: 16rem;
height: 6rem;
border-radius: 12px;
background: linear-gradient(
90deg in oklch,
oklch(70% 0.15 var(--hue)),
oklch(70% 0.15 calc(var(--hue) + 60deg))
);
animation: drift 12s linear infinite;
}
@keyframes drift { to { --hue: 610deg; } } /* one full turn, 250 → 610 */
@media (prefers-reduced-motion: reduce) {
.tile, .sheen { transition: none; }
.aurora { animation: none; }
}
</style>
</head>
<body>
<div class="tile" tabindex="0" role="img" aria-label="Gradient tile"></div>
<button class="sheen" type="button">Upgrade</button>
<div class="aurora" role="img" aria-label="Slowly shifting colour band"></div>
</body>
</html>
The in oklch after the gradient direction sets the interpolation space for the gradient's own colour blending, which keeps the midpoint between pink and amber vivid instead of muddy. The aurora's keyframe animates --hue from 250 to 610 degrees — one full turn — and because hue is an angle, the rendering at 610 degrees is identical to 250, so the loop has no visible seam.
The key technique: choose the cheapest input to animate
The three examples differ sharply in cost, and the difference guides which to use.
background-position animation reuses the rasterised gradient and only changes where it is drawn, which is why it is the standard technique for shimmer effects such as skeleton loaders. Registered-property animations are more expressive — they can change colours, stops and angles — but the browser must build a new gradient and repaint the element for every frame. That is inexpensive for a button-sized element over 400 milliseconds, and noticeably costly for a full-viewport hero animating forever.
When a large animated gradient is unavoidable, render it on a separate layer the size of the viewport, animate transform or opacity on overlapping static gradients instead of the gradient itself, or reduce its resolution with a small element scaled up. All of these keep the per-frame work off the main thread.
Colour spaces and hue direction inside the gradient
There are two interpolations happening in an animated gradient, and both have a colour space. The first is across the gradient: how the browser blends from one stop to the next along its length. The second is over time: how a registered colour property moves from its start value to its end value during the transition. Each can go through grey if it is done in sRGB between saturated, distant hues.
For the gradient itself, the colour interpolation method is written after the direction: linear-gradient(90deg in oklch, …). OKLCH keeps lightness and chroma steady while hue changes, so a blue-to-orange gradient passes through vivid intermediate colours rather than a grey band in the middle. When the two hues are far apart, a hue interpolation keyword chooses which way round the colour wheel to go: in oklch longer hue travels the long way, producing a rainbow-like sweep, while the default shorter hue takes the direct route.
For the transition over time, registered <color> properties interpolate in OKLab by default in current engines, which already avoids most muddy midpoints. Where a particular path matters — say, a hover that should pass through purple on the way from blue to pink — animating a registered <angle> for hue, as the aurora example does, gives complete control over the route, because the hue itself is the animated value.
The practical rule: write gradients in oklch, animate hue as an angle when the path matters, and check the midpoint frame of any colour transition as carefully as its endpoints. A transition that looks good at 0% and 100% but passes through a dull grey at 50% will look broken to users even though no single frame is "wrong".
Variation: gradient text that shifts on focus
Gradient text uses background-clip: text with transparent text colour. The same registered-property technique animates it, and it is a good example of where contrast needs checking at every frame, not just the endpoints.
@property --stop { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
.gradient-link {
color: transparent;
background: linear-gradient(90deg in oklch, #4338ca var(--stop), #be185d calc(var(--stop) + 60%));
background-clip: text;
transition: --stop 500ms ease;
text-decoration: underline;
text-decoration-color: #4338ca; /* the underline keeps a solid colour */
}
.gradient-link:hover,
.gradient-link:focus-visible {
--stop: 40%;
}
@media (forced-colors: active) {
.gradient-link { color: LinkText; background: none; }
}
The forced-colors rule is not optional: in Windows High Contrast and similar modes, background images are removed, and transparent text would become invisible. Restoring a system colour keeps the link readable. Both gradient endpoints must individually meet text contrast against the page, since each part of the word spends time at each colour.
Browser support
@property is supported in Chrome and Edge 85+, Firefox 128+ and Safari 16.4+; without it, registered-property animations jump at the end instead of interpolating. Gradient interpolation colour spaces (in oklch) and oklch() colours are supported in current versions of all three engines. background-position animation and background-clip: text are universally supported in current browsers, with -webkit-background-clip: text still worth including for older WebKit. forced-colors is supported in Chrome 89+, Edge 79+, Firefox 89+ and Safari 16+.
FAQ
Why does my gradient jump instead of transitioning?background-image is not an interpolable property, so a transition between two gradients switches instantly at the end. Animate the parts instead: register the colours or stop positions as custom properties with @property and transition those, and the gradient is rebuilt smoothly every frame.
What is the cheapest way to animate a gradient?
Make the gradient larger than its element with background-size and animate background-position. The gradient is drawn once and only its offset changes, which avoids rebuilding the image each frame. Registered-property animations are more flexible but repaint every frame.
Why does my animated gradient pass through grey?
The colours are interpolated in sRGB, where the straight line between two saturated complementary colours passes near grey. Interpolate the gradient in oklch, with in oklch after the gradient direction, and pick a hue direction such as longer hue so the path stays saturated.
Should animated gradient backgrounds loop forever?
Rarely. Large, slowly shifting gradients behind text are distracting and can trigger vestibular discomfort. If a background must move, keep it slow and subtle, stop it after a few cycles or when off screen, and disable it entirely for prefers-reduced-motion.
Related
- Color & Theme Transitions — the parent guide.
- Animated Gradient Borders With @property — the same technique applied to a border ring.
- Registered Properties and Type Safety — why registration enables interpolation.
- Aspect Ratio for Responsive Media — sizing the boxes these gradients fill.
Related articles
More pages in the same section.