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:
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.
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.
@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%.
@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.
@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;
}