CSS Scroll-Driven Animations

In a recent project at work, I started exploring CSS scroll-driven animations to transition elements in a sticky header between a larger and smaller size. After just a couple hours of exploration I had a working prototype using scroll animations to replace the much less reliable and smooth JavaScript based animation.

Having just scratched the surface of what is possible with scroll-driven animations, I knew a blog post was in order to show off some of the incredible features of scroll-driven animations.

What are animation timelines?

Before diving into scroll-driven animations, we first need to understand what animation timelines are. Historically, the only animation timeline that existed was the document timeline which will start incrementing when the page loads. As long as the page remains open, the document timeline will continue to increment. We can observe this by logging the document.timeline variable in our console.

console.log(document.timeline) // { currentTime: 62199.911, duration: null }

When you apply animations to elements, they use the document timeline to track the progress of the animation including any delays before starting the animation, and then the duration of the animation itself.

Because scroll-driven animations are based on scroll progress and not time durations, the document timeline doesn’t make sense when starting to think about animating elements based on scroll position. This is why scroll-driven animations introduced the animation-timeline property which allows us to target two new types of animation timelines that will be used to create scroll-driven animations.

Using the Scroll Timeline

Enough talk, let’s see an example! We’ll start with a rather simple example showing a blue box that rotates as it’s container scrolls. Try scrolling the container below to see it rotate!

Let’s take a look at the code for this simple animation. The HTML structure is quite simple, just three nested divs.

<div className="container">
  <div className="scroll-area">
    <div className="box" />
  </div>
</div>

For the CSS, we have some fairly typical code to define a set of keyframes for our animation, set up our container and scroll area, and then style and position our box in the center of the container. The highlighted lines are certainly the most interesting, as that’s where we define our animation.

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.container {
  height: 480px;
  overflow-y: scroll;
}

.scroll-area {
  height: 1200px;
}

.box {
  animation: spin linear;
  animation-timeline: scroll();
  aspect-ratio: 1;
  left: calc(50% - 60px);
  position: relative;
  top: calc(50% - 60px);
  width: 120px;
}

We specify the animation just like we would with any other CSS animation by referencing our set of keyframes (spin), and an easing function (linear). You may noticed that we haven’t added an animation duration, which again makes sense since our animation is tied to scroll progress rather than a time-duration. Finally, the most important part is specifying animation-timeline: scroll() which tells the browser that our animation should be based on the scroll progress of the nearest scroll ancestor.

Just like that, we’ve built our first scroll-driven animation!

More use cases for the scroll timeline

That previous example was fun, but it’s not all that useful to just make a box spin as the user scrolls down the page. There are plenty of use cases though where you might want to create animations using the scroll timeline, such as:

  • Adding a reading progress indicator for a blog post.
  • Applying a shadow or similar styling to a sticky header as you begin to scroll.
  • Slide in a newsletter reminder when the user scrolls half way down the page.

Let’s go ahead and implement that second idea of adding styling to a sticky header as you scroll the page. Before we see how this works with scroll animations, let’s first take a look at the kind of code you might think of for a task like this.

let [isScrolled, setIsScrolled] = useState(false)

useEffect(() => {
  function onScroll() {
    setIsScrolled(window.scrollY > 0)
  }

  onScroll()
  window.addEventListener("scroll", onScroll, { passive: true })
  return () =>
    window.removeEventListener("scroll", onScroll, { passive: true })
}, [])

return <header className={isScrolled ? "shadow-lg" : ""}></header>

In the code above, we are using some state to track whether the window has been scrolled so we can conditionally apply styles. Those styles might be a shadow like shown above, or perhaps shrinking an image to a smaller size. Regardless of the specifics, that’s how it might look.

With scroll-driven animations, we can get a bit fancier. Try scrolling the container below to see how the header background color slowly fades in as your scroll the container.

Lorem AI

Ea ad non sint ex adipisicing. Sunt est magna do. Esse ex officia velit duis officia duis voluptate laboris id. Minim proident nostrud deserunt. Do aliquip tempor commodo anim exercitation consequat nostrud aute eiusmod ea pariatur proident. Exercitation velit adipisicing laborum Lorem ea ad aliqua nisi pariatur eiusmod. Sint pariatur elit proident proident et do. Adipisicing ex duis qui consectetur mollit.

Cillum veniam ex eu ullamco exercitation magna dolore aliqua minim consectetur dolore. Lorem labore ea tempor veniam in. Irure cupidatat sint amet duis consectetur non cupidatat reprehenderit exercitation est exercitation proident consequat magna ullamco. Aliquip eiusmod commodo tempor mollit incididunt qui ex ipsum non nostrud cupidatat ipsum qui in. Laborum aliquip laboris elit. Aute aliquip incididunt magna sint consectetur est culpa. Lorem cillum aute commodo quis occaecat proident eu. Laboris nostrud excepteur mollit eu amet commodo et qui qui.

The HTML is once again very simple, just a container div that holds our header and the main content of the page.

<div class="container">
  <header>Lorem AI</header>
  <main>...</main>
</div>

The CSS is very similar to our first example containing some keyframes, making our container scrollable, and some animation properties for our header.

@keyframes header {
  from {
    backdrop-filter: blur(0);
    background: transparent;
  }
  to {
    backdrop-filter: blur(8px);
    background: rgb(30 41 59 / 0.75);
  }
}

.container {
  height: 320px;
  overflow-y: scroll;
  position: relative;
}

header {
  animation: header linear forwards;
  animation-timline: scroll();
  animation-range: 0 80px;
  position: sticky;
  top: 0;
  width: 100%;
}

There are a couple differences with the animation declaration from our last example, most prominently being the new animation-range keyword. This instructs the browser the start and end scroll positions to use as the start and end of the animation timeline. If not specified, this would be the start and end of our scroll container, but by specifying a range of 0 80px, the browser will start the animation immediately after starting to scroll our container, and finish when the user has scrolled 80px.

With the addition of animation-range, we also need to specify an animation-fill-mode which is what we’ve done with the forwards keyword in the animation property. This ensures that once we’ve scrolled past 80px in the container, the element will retain the values from the last keyframe in the animation, which in our case is the background color and blur effect. If we don’t specify this property, our element will reset back to it’s initial values before the animation took place which is not the intended behavior.

Using the View Timeline

By now you’re probably thinking, “wow, scroll-driven animations are awesome”. However, we have just scratched the surface of this API by talking about using the scroll() timeline. Now it’s time that we talk about the view() timeline which unlocks even more capabilities we can use in our app.

The view timeline is another scroll-driven animation timeline, however rather than based on the scroll progress of the scroll container, it is based on the progress of a given element within the viewport. In the demo below, as the image scrolls into the viewport, it scales to full size and increases opacity to 100% to give a nice “pop” effect.

Lorem AI

Suspendisse luctus gravida sapien eu tincidunt. Vivamus laoreet arcu ut sodales scelerisque. Nam venenatis ullamcorper felis vel blandit. Vestibulum neque libero, fermentum a tortor vel, convallis fermentum est. Fusce porttitor interdum sem, in placerat nisl finibus ut. Nulla facilisi. Praesent sollicitudin quis eros quis faucibus. Nam vel sollicitudin nisl. Mauris gravida facilisis massa, vitae aliquet urna ornare vitae. Nullam gravida quam dui, sed blandit urna mollis sed.

Vivamus egestas auctor dui ut elementum. Integer id sem varius, fringilla nibh at, semper ante. Aliquam erat volutpat. Nulla facilisi. Morbi ac sapien sed purus consequat volutpat. Suspendisse nec nulla vel augue sollicitudin finibus ac tempor ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; In lobortis est mauris, et imperdiet elit tempus eu. Vivamus lectus justo, molestie a ullamcorper et, fringilla ut turpis. Sed dictum efficitur odio non consequat. Mauris volutpat molestie felis. Sed at laoreet justo.

Placeholder

Quisque eleifend iaculis odio, euismod malesuada dui pellentesque nec. Nullam id accumsan risus. Nam id venenatis diam. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nunc fermentum tempor nulla sed finibus. Suspendisse dolor augue, commodo quis quam vel, pharetra lacinia leo. Suspendisse mollis neque eget eleifend cursus. Vestibulum nunc orci, tristique a suscipit convallis, vehicula et arcu. Praesent venenatis tellus vitae massa hendrerit, id auctor lorem tristique.

The HTML is again fairly simple, a container with some paragraphs and an image.

<div class="container">
  <p>...</p>
  <img />
  <p>...</p>
</div>

The CSS will also look quite similar to the example previously with the scroll() timeline, except in this case we are using the view() timeline. The view timeline starts when the element enters the viewport, and ends when the element leaves the viewport.

By adjusting the animation-range, we are able to adjust how quickly the animation completes. Using the value of 0 50%, the image will be fully animated in when 50% of it is in the viewport. This could be adjusted higher or lower based on your use case, but a value between 30-50 is a good starting point to provide some visual flair, without harming the user experience.

@keyframes pop {
  from {
    opacity: 0;
    transform: scaleX(90%);
  }
  to {
    opacity: 1;
    transform: scaleX(100%);
  }
}

.container {
  height: 400px;
  overflow: none scroll;
}

img {
  animation: pop linear;
  animation-range: 0 50%;
  animation-timeline: view();
}

What once took heavy JavaScript libraries is now something we can do with a few lines of HTML and CSS.

Wrapping Up

This blog post has been sitting on the shelf for a bit as my initial ambitions for it were more than I was able to feasibly accomplish when I started writing it. Also, there is only so much you can show with scroll-driven animations before it all is just the same thing done ever so slightly differently. So while I’d love to provide some more demos and neat visual effects, it’s time to call it a wrap, and ship this post already.

I really do hope you give scroll-driven animations a try, they are a remarkable new feature of the web platform, and something I’m excited to see slowly become a normal part of building websites and web apps. When I starting writing this post, I actually used it as an opportunity to update the header of this site to use scroll driven animations for the header blur effect. Simple animations like that can go a long way to making your site just a bit more pleasing to use. Just give it a try, it’ll be worth it.