Back

Animated CSS Gradients

  • May 9, 2025
  • CSS, HTML

I recently designed a chatbot screen with limited real estate, where we needed to prioritize content but also show a thinking state without losing context. Prioritizing content meant avoiding unnecessary icons and controls, so I relied on a background image to show that the bot was composing a response.

The background was a branded “AI” pearlescent gradient, and when thinking, I wanted the gradient to loop over a pulsing animation. This pen demonstrates a simplified version of this method, where I create a gradient with negatively-positioned stops, and animate their position in the gradient.

To accomplish this, we define our typical gradient as stops between 0 and 100:

CSS
el {
  background-image:
    radial-gradient(circle at 50% 100%,
      rgb(180, 241, 255) 0%,
      rgb(90, 225, 255) 28.5%,
      rgb(226, 109, 255) 67.5%,
      rgb(0, 115, 255) 100%);
}

We then repeat those stops offset by -100%. Notice that stops outside the 0-100 range do not render, effectively allowing us to create an animation timeline, with gradient stop as time.

CSS
el {
  background-image:
    radial-gradient(circle at 50% 100%,
      rgb(180, 241, 255) -100%,
      rgb(90, 225, 255) -71.5%,
      rgb(226, 109, 255) -32.5%,
      rgb(0, 115, 255) 0%,
      rgb(180, 241, 255) 0%,
      rgb(90, 225, 255) 28.5%,
      rgb(226, 109, 255) 67.5%,
      rgb(0, 115, 255) 100%);
}

Let’s animate it.

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

@keyframes progress {
  0% {
    --progress: 0%;
  }

  100% {
    --progress: 100%;
  }
}

el {
  animation-name: progress;
  animation-duration: 2s;
  animation-iteration-count: infinite;
  animation-fill-mode: forwards;
  animation-timing-function: ease-in-out;
  background-image:
    radial-gradient(circle at 50% 100%,
      rgb(180, 241, 255) calc(-100% + var(--progress)),
      rgb(90, 225, 255) calc(-71.5% + var(--progress)),
      rgb(226, 109, 255) calc(-32.5% + var(--progress)),
      rgb(0, 115, 255) calc(0 + var(--progress)),
      rgb(180, 241, 255) calc(0% + var(--progress)),
      rgb(90, 225, 255) calc(28.5% + var(--progress)),
      rgb(226, 109, 255) calc(67.5% + var(--progress)),
      rgb(0, 115, 255) calc(100% + var(--progress)));
}

Given that this gradient has different starting and ending values, it animates with a rough transition between loops. We can either revise the gradient to have the same color value at 0% and 100%, or we can animate the color at 0% to the color at 100%.

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

@property --fadingColor {
  syntax: '<color>';
  inherits: false;
  initial-value: white;
}

@keyframes progress {
  0% {
    --progress: 0%;
  }

  100% {
    --progress: 100%;
  }
}

@keyframes fadingColor {
  0% {
    --fadingColor: rgba(180, 241, 255, 1);
  }

  100% {
    --fadingColor: rgba(0, 115, 255, 1);
  }
}

el {
  animation-name: progress, fadingColor;
  animation-duration: 2s;
  animation-iteration-count: infinite;
  animation-fill-mode: forwards;
  animation-timing-function: ease-in-out;
  background-image:
    radial-gradient(circle at 50% 100%,
      rgb(180, 241, 255) calc(-100% + var(--progress)),
      rgb(90, 225, 255) calc(-71.5% + var(--progress)),
      rgb(226, 109, 255) calc(-32.5% + var(--progress)),
      rgb(0, 115, 255) calc(0 + var(--progress)),
      rgb(180, 241, 255) calc(0% + var(--fadingColor)),
      rgb(90, 225, 255) calc(28.5% + var(--progress)),
      rgb(226, 109, 255) calc(67.5% + var(--progress)),
      rgb(0, 115, 255) calc(100% + var(--progress)));
}

Let’s put it all together. There’s a leap between these examples — this resembles much more the solution I used for myself than the step-by-step breakdown I’ve described, where I considered animating through a much more complex gradient.

CSS
@use 'sass:list';

$gradientLoops: 2;
$dur: 4s;

$colors: (
  rgb(180, 241, 255),
  rgb(90, 225, 255),
  rgb(226, 109, 255),
  rgb(0, 115, 255),
  rgb(0, 85, 255)
);

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

// outputs property definitions for each potential animated color
@for $loop from 1 through $gradientLoops {
  @for $color from 1 through list.length($colors) {
    @property --color#{$color}Loop#{$loop} {
      syntax: '<color>';
      inherits: false;
      initial-value: white;
    }
  }
}

@keyframes colorProgress {
  0% {
    --progress: 0%;
  }

  100% {
    --progress: #{($gradientLoops - 1) * 100%};
  }
}

@keyframes colorFade {
  0% {
    --color1Loop1: #{list.nth($colors, 1)};
    --color1Loop2: #{list.nth($colors, 1)};
    --color2Loop1: #{list.nth($colors, 2)};
    --color2Loop2: #{list.nth($colors, 2)};
    --color3Loop1: #{list.nth($colors, 3)};
    --color3Loop2: #{list.nth($colors, 3)};
    --color4Loop1: #{list.nth($colors, 4)};
    --color4Loop2: #{list.nth($colors, 1)};
  }

  100% {
    --color1Loop1: #{list.nth($colors, 4)};
    --color1Loop2: #{list.nth($colors, 1)};
    --color2Loop1: #{list.nth($colors, 5)};
    --color2Loop2: #{list.nth($colors, 2)};
    --color3Loop1: #{list.nth($colors, 3)};
    --color3Loop2: #{list.nth($colors, 3)};
    --color4Loop1: #{list.nth($colors, 5)};
    --color4Loop2: #{list.nth($colors, 4)};
  }
}

@function reverseList($list, $separator: comma) {
  $reversedList: null;

  @for $i from list.length($list) to 0 {
    $reversedList: list.append($reversedList, list.nth($list, $i), $separator);
  }

  @return $reversedList;
}

@function animated-gradient() {
  $stops: null;

  @for $i from 1 through $gradientLoops {
    $newStops: var(--color1Loop#{$i}) calc(0% - #{$i - 1} * 100% + var(--progress)),
      var(--color2Loop#{$i}) calc(28.5% - #{$i - 1} * 100% + var(--progress)),
      var(--color3Loop#{$i}) calc(67.5% - #{$i - 1} * 100% + var(--progress)),
      var(--color4Loop#{$i}) calc(93% - #{$i - 1} * 100% + var(--progress));
    $stops: list.join($stops, reverseList($newStops), $separator: comma);
  }

  $gradient: radial-gradient(circle at 50% 5%,
      reverseList($stops));
  @return $gradient;
}

el {
  background: animated-gradient();
  animation-name: colorProgress, colorFade;
  animation-duration: $dur;
  animation-iteration-count: infinite;
  animation-fill-mode: forwards;
  animation-timing-function: ease-in-out;
}