CSS Gradient Transitions: 5 Methods

You can’t transition one CSS gradient into another with transition: background-image. The browser treats a gradient as an image, and it swaps images instantly instead of blending them.

There are still five reliable ways to get a smooth gradient transition or animation:

  1. @property: register the gradient’s colors and angle as typed custom properties, and transition those. This is the only method that truly morphs the gradient.
  2. Opacity cross-fade: put the second gradient on a pseudo-element and fade it in. It’s the cheapest one to animate.
  3. background-position: make the gradient larger than the element and slide it, for a flowing background.
  4. background-color under a transparent gradient: transition the color that shows through the gradient.
  5. hue-rotate(): shift every color of the gradient around the color wheel.

The first version of this post covered only the cross-fade, which used to be the best option. Now that @property works in every major browser, I rewrote the post around all five methods, with a live demo for each.

Why transition: background-image Doesn’t Work on Gradients

This is the code most people try first:

.card {
  background-image: linear-gradient(120deg, #ffb42f, #840d32);
  transition: background-image 0.6s ease;
}

.card:hover {
  background-image: linear-gradient(300deg, #2a5d8f, #4ead80);
}

The gradient doesn’t fade. It snaps to the new one as soon as you hover.

The odd part is that the spec allows it. The CSS Images Level 4 draft says:

Gradient images can be interpolated directly in CSS transitions and animations, smoothly animating from one gradient to another.
CSS Images Module Level 4, Editor’s Draft

Browsers haven’t implemented that part. In my test in Chrome 156, the computed background-image was already the final gradient halfway through a one-second transition. Chrome does cross-fade between two url() images, but not between gradients.

Adding transition-behavior: allow-discrete doesn’t help either. It only delays the swap to the halfway point. Compare the two approaches here:

Demo 1: background-image vs @property
transition: background-imagejumps
first stoprgb(255, 180, 47)
@propertymorphs
--fromrgb(255, 180, 47)

Hover the tiles, tap them, or press Toggle. Pick 3s and watch the readouts: the left value jumps straight to the end color, the right one passes through every color in between.

The right tile uses Method 1. The rest of this post explains it and the four alternatives.

Which Gradient Transition Method Should You Use?

Here’s the short version:

Method What changes Repaints every frame? Best for
@property Colors, angle and stop positions, smoothly Yes Hover and state changes on buttons and cards
Opacity cross-fade Fades between two fixed gradients No Large backgrounds and long animations
background-position Slides one large gradient Yes, unless you move it with transform Flowing animated backgrounds
background-color The color behind a transparent gradient Yes Subtle hover tints
hue-rotate() All hues at once, around the color wheel No (measured in Chrome) Decorative color cycling

Method 1: Transition Gradient Colors with @property

To the browser, an unregistered custom property is plain text, so it has no way to interpolate it. @property registers a custom property with a type. Once the browser knows that --from is a <color>, it can transition it like any other color, and the gradient that reads it redraws on every frame:

@property --from {
  syntax: '<color>';
  inherits: false;
  initial-value: #ffb42f;
}

@property --to {
  syntax: '<color>';
  inherits: false;
  initial-value: #840d32;
}

@property --angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 120deg;
}

.card {
  background-image: linear-gradient(var(--angle), var(--from), var(--to));
  transition:
    --from 0.6s ease,
    --to 0.6s ease,
    --angle 0.6s ease;
}

.card:hover,
.card:focus-visible {
  --from: #2a5d8f;
  --to: #4ead80;
  --angle: 300deg;
}

Each color stop now moves on its own path, and so does the angle. That’s the real difference from a cross-fade: the gradient itself changes, instead of one picture fading into another.

Note the :focus-visible selector next to :hover. Keyboard users get the same effect when they tab to the card.

Browsers without @property support fall back to the instant swap, so nothing breaks. Support starts at Chrome and Edge 85, Safari 16.4 and Firefox 128.

Drive a Whole Gradient with One Value

You can also register a single percentage and mix each stop between two colors with color-mix(). One custom property then controls the whole gradient:

@property --mix {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

.card {
  background-image: linear-gradient(
    120deg,
    color-mix(in oklab, #2a5d8f var(--mix), #ffb42f),
    color-mix(in oklab, #4ead80 var(--mix), #840d32)
  );
  transition: --mix 0.6s ease;
}

.card:hover,
.card:focus-visible {
  --mix: 100%;
}

This is handy when JavaScript sets the value, for example from scroll progress. For angles, hue cycling and animated borders, see the complete guide to CSS @property.

Method 2: Cross-Fade Two Gradients with opacity

This is the classic workaround, and it’s still the right choice for big surfaces. Put the second gradient on a pseudo-element that covers the element, hide it with opacity: 0, and fade it in:

.card {
  position: relative;
  isolation: isolate;
  background-image: linear-gradient(120deg, #ffb42f, #840d32);
}

.card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background-image: linear-gradient(120deg, #2a5d8f, #4ead80);
  opacity: 0;
  transition: opacity 0.6s ease;
}

.card:hover::before,
.card:focus-visible::before {
  opacity: 1;
}

isolation: isolate creates a new stacking context, so z-index: -1 places the pseudo-element above the element’s own background and below its text. Without it, the layer slips behind the element and you never see it.

The advantage is performance. According to web.dev, transform and opacity are the only two properties a browser can animate on the compositor alone, without repainting. So this method stays smooth even on a full-screen hero.

Demo 2: opacity cross-fade
::before fades in
::beforeopacity: 0.00

Only the opacity of the top layer changes. Both gradients keep their angle: a cross-fade can blend two pictures, but it cannot turn one.

The limit is that it fades between two fixed pictures. It can’t turn the angle or move a color stop.

Method 3: Animate background-position for a Flowing Gradient

If you want a gradient that keeps moving, make it bigger than the element and slide it around. The colors stay the same, and only the visible part changes:

.hero {
  background-image: linear-gradient(120deg, #ffb42f, #840d32, #2a5d8f, #4ead80);
  background-size: 300% 300%;
  animation: gradient-flow 6s ease-in-out infinite alternate;
}

@keyframes gradient-flow {
  from { background-position: 0% 0%; }
  to { background-position: 100% 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .hero {
    animation: none;
  }
}

alternate plays the animation forward and then backward, so it never jumps back to the start.

Always wrap looping animations in a prefers-reduced-motion check. People who turn on reduced motion in their system settings then get a still gradient.

A Cheaper Version with transform

background-position repaints the element on every frame. For a large hero, put the oversized gradient on a pseudo-element and move that layer with transform instead:

.hero {
  position: relative;
  isolation: isolate;
  overflow: hidden;
}

.hero::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  z-index: -1;
  width: 300%;
  height: 300%;
  background-image: linear-gradient(120deg, #ffb42f, #840d32, #2a5d8f, #4ead80);
  animation: gradient-slide 6s ease-in-out infinite alternate;
}

@keyframes gradient-slide {
  from { transform: translate(0, 0); }
  to { transform: translate(-66.6667%, -66.6667%); }
}

@media (prefers-reduced-motion: reduce) {
  .hero::before {
    animation: none;
  }
}

Percentages in translate() refer to the layer’s own size, so -66.6667% of a 300% layer moves it by twice the element’s size. That’s the same end point as background-position: 100% 100%.

The result looks identical. In my Chrome trace, the transform version didn’t repaint once, while the background-position version repainted on every frame. Try both in the demo:

Demo 3: flowing gradient, background-position vs transform
gradient at 300% size

The gradient is three times larger than the tile and slides back and forth. Switch to transform while it plays: the picture is identical, but the browser no longer repaints it on every frame.

Method 4: Transition background-color Under a Transparent Gradient

A gradient can fade into transparent, which lets the element’s background-color show through. background-color transitions normally, so changing it changes part of the gradient:

.card {
  background-color: #840d32;
  background-image: linear-gradient(120deg, #ffb42f, transparent 70%);
  transition: background-color 0.6s ease;
}

.card:hover,
.card:focus-visible {
  background-color: #2a5d8f;
}

This is the oldest trick here, and it works in every browser. It only changes the color behind the gradient, so it suits subtle hover tints better than full color swaps.

Demo 4: background-color under a transparent gradient
background-color shows through
background-colorrgb(132, 13, 50)

The gold stop stays put. Only the color behind the transparent part of the gradient changes.

Method 5: Shift Gradient Colors with filter: hue-rotate()

hue-rotate() turns every color around the color wheel. Animate it from 0 to 360 degrees and the gradient cycles through the spectrum:

.hero {
  position: relative;
  isolation: isolate;
}

.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background-image: linear-gradient(120deg, #ffb42f, #840d32);
  animation: hue-cycle 6s linear infinite;
}

@keyframes hue-cycle {
  to { filter: hue-rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .hero::before {
    animation: none;
  }
}

Put the filter on a pseudo-element, not on the element itself. A filter applies to everything inside the element, so text, icons and badges change color too.

The animation itself is cheap either way. In my Chrome test, an animated hue-rotate() filter didn’t repaint once, on the element or on a pseudo-element.

Demo 5: hue-rotate() on the pseudo-element vs the element
hue-rotate()Badge

Press Play, then switch to "Filter on the element". The gold badge starts changing color too, because a filter applies to everything inside the element.

Other Options: SVG Gradients and View Transitions

Two more techniques are worth knowing, even though neither needs a full section.

SVG gradients. In an inline SVG, each gradient stop is an element with a stop-color:

<svg viewBox="0 0 300 120" width="300" height="120">
  <defs>
    <linearGradient id="card-gradient">
      <stop class="stop-1" offset="0" />
      <stop class="stop-2" offset="1" />
    </linearGradient>
  </defs>
  <rect width="300" height="120" rx="6" fill="url(#card-gradient)" />
</svg>
.stop-1 { stop-color: #ffb42f; transition: stop-color 0.6s ease; }
.stop-2 { stop-color: #840d32; transition: stop-color 0.6s ease; }

svg:hover .stop-1 { stop-color: #2a5d8f; }
svg:hover .stop-2 { stop-color: #4ead80; }

In my test in Chrome 156, the stop-color transition ran smoothly. MDN lists stop-color as discretely animated, so test it in Firefox and Safari before you rely on it.

View Transitions. document.startViewTransition() takes a snapshot before and after a DOM change and cross-fades the two. Swap the gradient class inside the callback and you get a cross-fade without a pseudo-element:

const card = document.querySelector('.card');

card.addEventListener('click', () => {
  if (!document.startViewTransition) {
    card.classList.toggle('is-alt');
    return;
  }
  document.startViewTransition(() => card.classList.toggle('is-alt'));
});

Same-document view transitions work in Chrome and Edge 111, Safari 18 and Firefox 144. The fallback in the code swaps the class without the animation.

If you want a gradient that fades content out rather than changes color, that’s a job for CSS mask-image.

Performance and Accessibility

A few rules apply whichever method you pick:

  • Match the method to the surface. A repaint on every frame is fine for a button. For a full-screen background that animates for seconds, use the opacity cross-fade or the transform version of Method 3.
  • Know what repaints. In a two-second trace in Chrome 156, a 900 by 500 pixel element animated with @property, background-position or background-color repainted on every one of about 355 frames. The opacity cross-fade, the transform layer and hue-rotate() did not repaint at all.
  • Pause what nobody sees. A looping animation keeps running after it scrolls out of view. In my test, a playing background-position demo kept repainting while it was off-screen. The demos on this page pause themselves with an IntersectionObserver.
  • Respect reduced motion. Short hover transitions are fine. Looping animations should stop under prefers-reduced-motion: reduce.
  • Don’t rely on hover alone. Add :focus-visible so keyboard users see the same change, and remember that phones have no hover.
  • Check contrast at both ends. Text has to stay readable on the first gradient, the last one, and everything in between.
The same @property approach animates gradient borders, which are covered in gradient borders in CSS. For more ideas on buttons, see 47 creative button hover effects.

FAQs

Common questions about CSS gradient transitions:

Can you transition a linear-gradient in CSS?
Not directly. In current browsers, transition: background-image swaps one gradient for the other instantly. Register the gradient colors with @property and transition those, or fade a second gradient in with opacity.
How do I animate a gradient background in CSS?
For a moving background, make the gradient larger than the element with background-size: 300% 300% and animate background-position with @keyframes. For a color change, register the colors with @property. Stop looping animations for users who set prefers-reduced-motion.
Why doesn't transition work on background-image?
Browsers treat a gradient as an image and do not interpolate between two images, even though the CSS Images Level 4 draft allows it for gradients. When two values cannot be interpolated, the transition does not run and the new value applies immediately.
Which gradient animation method performs best?
The opacity cross-fade, because a browser can animate opacity on the compositor without repainting. In my Chrome test, a hue-rotate() filter and a gradient layer moved with transform also ran without repaints. @property, background-position and background-color repainted on every frame, which is fine for buttons and cards.
Does @property work in all browsers?
Yes, in current versions: Chrome and Edge 85, Safari 16.4 and Firefox 128 and later. Older browsers ignore the registration, and the gradient falls back to an instant swap.
How do I transition a gradient on hover?
Register each color as a <color> with @property, use the custom properties inside the gradient, add transition: --from 0.6s, and change the values in :hover and :focus-visible.
Can I animate the angle of a gradient?
Yes. Register the angle as an <angle> with @property, use it as the first argument of linear-gradient() or conic-gradient(), and transition or animate it. A cross-fade cannot do this.

Summary

You can’t transition background-image from one gradient to another, but you rarely need to. For a real morph of colors and angle, register them with @property. For large or long-running backgrounds, cross-fade a pseudo-element with opacity. For a moving background, slide an oversized gradient, preferably with transform.

If I had to pick one default today, it would be @property, with the cross-fade kept for big hero backgrounds. If you build something with these techniques, I’d like to see it in the comments.

Discussion
[  0 Comments  ]

Leave a Comment

To add code, use the buttons below. For instance, click the PHP button to insert PHP code within the shortcode. If you notice any typos, please let us know!