Summary

In this article, I present a short experiment to combine an animated background gradient with scroll animations in CSS.

New features in CSS always get me excited, even though maybe I am the only one with that quirk! I even get excited when my apps have new updates and versions, so I tend to meticulously check for new version and be the first to update when anything new is released. Sometimes even mess things up for myself doing that. This probably says something about me, that I don’t want to admit, but let’s not think about that too hard right now.

Research

The frontend development sphere is a very fast moving one, with new frameworks etc. being released basically daily, which has become a sort of running joke in the community:

Every time you blink, three new “JS frameworks” are released.

To keep up with that chaos, I read bogs and newsletters about recent browser features and other developments. But many articles mention features that are either really new or not yet fully available to all browser though. The frontend development scene moves so fast, it can be hard to be aware of what is currently possible or what is newly available. The Baseline project was introduced in 2023 to help developers gauge more easily which features are widely available and which are still cooking:

Web Platform Baseline brings clarity to information about browser support for web platform features. Baseline has two stages (source):

  • Newly available: The feature becomes supported by all of the core browsers, and is therefore interoperable.
  • Widely available: 30 months has passed since the newly interoperable date. The feature can be used by most sites without worrying about support.

Especially recently, browsers have been developing fast and releasing lots of new CSS features for developers to make their lives easier. Highlights here include CSS nesting, container queries and the :has() selector. Even more recently and in addition to the above, the Web Platform Dashboard was released, to give an overview and be able to search for new features and compare the availability data for them.

Idea

Reading about these, sometimes gives me ideas for little experiments or mini-projects, like in this article. I do these to learn the new features and understand how to use them for future projects. So while reading about a new feature called animation-timeline, I had an idea for an animated page background based on how far the user has scrolled the page. Scroll-based animations themselves are not new and usually done via some scroll listener in JavaScript, to make elements appear with an animation when scrolling down the page or highlight elements in similar ways. Furthermore, I also always liked animated backgrounds on websites that give the site a certain tangibility and immersion. So how about being able to animate a background with the new animation-timeline feature?

Steps

With that in mind, I tried to think of some steps, that would make sense for an idea like that.

Background Image

First comes the general concept of the background image, with a few things I wanted to achieve:

  • render a full page background
  • with several radial gradients in one background element, positioned on screen at opposite corners
  • cycle through colours and use complementary colours for opposite gradients

All of this is achieved with the following code snippet:

background:
	radial-gradient(
		circle at var(--scroll-percentage) var(--scroll-percentage),
		hsl(0deg 100% 10%),
		transparent 50%
	),
	radial-gradient(
		circle at calc(100% - var(--scroll-percentage)) var(--scroll-percentage),
		hsl(90deg 100% 10%),
		transparent 50%
	),
	radial-gradient(
		circle at calc(100% - var(--scroll-percentage))
		calc(100% - var(--scroll-percentage)),
		hsl(180deg 100% 10%),
		transparent 50%
	),
	radial-gradient(
		circle at var(--scroll-percentage) calc(100% - var(--scroll-percentage)),
		hsl(270deg 100% 10%),
		transparent 50%
	);

This basically just consists of a single background property, where you can either just define a single background colour/image/gradient or several at once, which is what I am using here to render several radial-gradients and position them on the page using a custom property, that I will explain more below. For the colour of each gradient I used the hsl colour format, to be able to easily “rotate around the colour wheel”, so using 0deg, 90deg, 180deg and 270deg to split the colour wheel in four.

All of this results in a background like this:

Animation

Now to animating that! My idea was to have these gradients move across the page, cross each other in the middle and move to the opposite sides of the screen.

I had to try several iterations of that, because animation a background gradient is surprisingly hard. At first, I wanted to use background-positon to position each gradient and then animate that property. But getting the right position for each gradient listed in background-position is rather tricky. For example, I first tried to use something like background-positon: 0 0, 100% 0, 0 100%, 100% 100% with a background-size: 200% 200%, and this works well for singular backgrounds, but not so well for several gradients. I couldn’t get a reliable position animation happening, the gradients always ended up in weird positions on the viewport, like offscreen somewhere else. I am still not fully sure why, but it also became quite the mess of properties and calculations, that was barely readable any more. So unhappy with this, I recalled reading that it’s possible to animate custom properties and even define their data types explicitly, as mentioned in other recent articles. This opens up a whole range of opportunities of animating any possible value, that are not usually animatable. For example, in this specific case, I want a custom property to function like a length percentage value, and therefore interpolate between 0% and 100%. This is done via the @property syntax:

@property --scroll-percentage {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 0;
}

With this in place, it’s easy to animate the gradients through the custom property now:

@keyframes background-position-diagonal {
  from {
    --scroll-percentage: 0%;
  }
 
  to {
    --scroll-percentage: 100%;
  }
}

We know have a standard animation we apply to the body element with the usual properties, like making it iterate infinitely and alternating for a certain time:

animation: background-position-diagonal;
animation-duration: 15s;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-timing-function: ease-in-out;

Which in the end looks like this:

four gradients animated

Scroll Animation

Now comes the new fancy trick, applying all this when the user scrolls the page. This is done with a new property animation-timeline, which has a few possible values, but here we are only interested in the scroll() one. This provides a “scroll progress timeline for animating the current element”, optionally specifying a specific element and axis in the brackets, as I have done below. We just have to unset the animation-iteration-count and specify a special value for animation-duration just for Firefox, as the feature is still rather experimental:

body.scrollable {
  animation-timeline: scroll(y root);
  animation-duration: 1ms; /* Firefox requires this to apply the animation */
  animation-iteration-count: unset;
}

Adjust for Screen Size

As already visible above, the animation timeline is only active when the class .scrollabe is applied, otherwise the normal above animation is playing. This class is used to be able to only use it when the page is actually scrollable. Which in turn means we need a way to toggle this class when the content is smaller or bigger than the viewport. This is surprisingly tricky! The go-to solution for most people would be to just add a listener for when the viewpoint size changes with JavaScript, and toggle the class then. But always like to avoid any JavaScript solutions, if there are “native alternatives” without. My first thought was if there is any media query to detect whether the content is higher than the viewport. But you can only query for specific heights and widths of the viewport. The next idea was to use container queries, but also no luck there. These are very new and allow querying for different aspects of a parent element. Unfortunately, they have a limitation when trying to query the height of the parent element, because the parent element collapses their height when defining it as a “block container”, so you need to set a fixed height, which somewhat causes a loop in the logic. This seems confusing and even contradicting to me, not to say, makes height-based container queries somewhat useless in my eyes. Not sure if that’s the intended behaviour, but looks to me, there is no way to query for height of a container properly then. Please do let me know if I misunderstand that and there is another way. Without any other ideas myself, the only option was to use a small JavaScript snippet to detect when the viewport is scrollable, as much as I would have preferred to avoid that. This script simply uses a ResizeObservers to detect whether the height of the body is smaller than the window. To make sure the effect only starts when there is enough to scroll, the height of the body has to be at least 1.5 times the height of the viewport as well:

import debounce from "lodash/debounce";
 
let className = "scrollable";
let observer = new ResizeObserver(
  debounce((entries) => {
    entries.forEach(() => {
      if (document.body.scrollHeight > window.innerHeight * 1.5) {
        document.body.classList.add(className);
      } else {
        document.body.classList.remove(className);
      }
    });
  }, 400),
);
observer.observe(document.querySelector("#scrollObserver"));

So with this script running, the body has the .scrollable class applied when it’s scrollable, but not if not.

One more idea I had after all this, whether it would be possible to transition between these two states somehow, meaning the gradients smoothly transition between their normal “times animation” and the “scrolled animation”. But unfortunately I don’t think that’s possible, as we don’t have any distinct states we can animate between here, but only the animation-timeline, so even something like in this video would not be impossible.

Result

And with all this in place, we have achieved the following result: You might have to open it directly on CodePen to see the scrolling effect.

See the Pen scrolling gradient by Kageetai (@Kageetai) on CodePen.

One Step Further

But there is more! Reading more about scroll-based animations, I found another article recently with another very interesting technique to detect a scrollable container without any JS. The main trick comes right from the W3 spec:

If the source of a ScrollTimeline is an element whose principal box does not exist or is not a scroll container, or if there is no scrollable overflow, then the ScrollTimeline is inactive.

This allows an interesting technique, which uses a custom property, that’s only set when the animation is activated, which in turn can then be used to detect whether the container is scrollable or not.

@keyframes detect-scroll {  
  to {  
    --can-scroll: 1;  
  }  
}

Because this animation is only active when the container is scrollable, due to animation-timeline: scroll(self) being used, the custom property can be used in other places as an indicator when we can scroll.

As the article shows, there are still different ways to go about this, but using “space toggles” gets messy very quickly and is rather hard to understand and I needed to trigger several CSS properties on the main container, only the option using style queries seemed feasible to me. This can look like this, for example:

@container style(--can-scroll: 1) {  
  body {  
    animation-duration: unset;  
    animation-timeline: scroll(y root);  
    animation-iteration-count: unset;  
  }  
}

This allows to remove the JavaScript used in the previous implementation, which is always favourable.

It has three small downsides though:

  1. Style queries are currently only available in Chromium-based browsers (at time of writing). Also, they can only be applied to child elements, therefore I had to split the scrolling detection and the animation itself to the :root and the body elements.
  2. We can not detect changes in “scrollability”, as the above “animation hack” gets triggered once, but can’t unset the value of the custom property, at least not that I am aware. It works one way, when making the viewport smaller and it becomes scrollable. For this case, the animation is activated and therefore the custom property set, but not the other way around. Usually users don’t resize their browser much, therefore this could be an acceptable tradeoff.
  3. I cannot add a “scroll margin” for when to switch to the scrolling animation, as I did above in the JavaScript snippet. Therefore, the scrolling animation can look very fast, when there is only very little space to scroll.

With this in mind, the code becomes considerably simpler and easier to understand though, and as this is an experiment to try out new technologies, I consider this a very interesting solution at least.

The end result looks like this:

See the Pen scrolling gradient by Kageetai (@Kageetai) on CodePen.

Performance

An important consideration with new technologies like these, is of course how it affects performance in the browser and especially mobile devices. Luckily, a simple performance test can be quickly done in the Chrome Dev Tools via the “Frame Rendering Stats”:

120 FPS on MacBook Pro M~80 FPS on Pixel

As you can see it could be better on my slightly dated mobile device (second image), but generally, I would consider performance good, for a simple example like this. Of course, as always test your app in a real-life situation, if you wanna use a more complex effect like this. Animations are a complicated topic, and especially bad performance can make them look very bad, or worst case even have side effects for some users. Therefore, it’s usually good to check for the prefers-reduced-motion flag via a media query, but for an experiment testing a specific animation like this, the animation is kind of the whole point. Unfortunately, there is one downside I learned from a recent article with the way I am using CSS custom properties to animate the gradients: They cause the browser to repaint every on every frame, unlike animating other properties like transform or so. This becomes visible when using the Chrome Dev Tools to highlight repaints and they are already visible in the screenshots above as the yellow lines in the graph. As it stands, I don’t think there is another way to animate background gradients using the CSS properties, which don’t cause repaints, so I gave to live with that knowledge, it seems. A good article giving tips how to debug rendering performance of your app, can be found here: Investigate Animation Performance with DevTools - Calibre

Compatibility

Of course, for new features like these, it’s important to keep in mind whether they are already supported widely or not yet. As already mentioned above, the wonderful Baseline project, shows a lot of useful information about that. The main feature I am testing with this experiment is the scroll-timeline, which has the following compatibility data (from MDN as of writing):

One more feature that’s not widely available yet, is the @property custom property definitions, which is also not in Firefox yet. But with either these two missing for Firefox, it will degrade nicely to the default animation.

The End

Anyway, I hope this little write-up of my little mini-project was insightful or you even learned something. Scroll-driven animations are still quite new, so here is a nice resource if you want to learn more about them: Scroll-driven Animations Here is also a nice playlist on YouTube, if you prefer to learn more visually: Before you continue to YouTube

Thanks for reading this little “rambly” experiment of mine and I look forward to any comments, suggestions or otherwise messages.