Skip to main content

available March 2025

This portfolio uses the latest CSS features, like scroll driven animations and view transitions, your browser does not support (all of) them. I've added fallback animations, but for the best experience, please consider using a modern chromium-based browser.

January 15, 2025

Written by: Cyd Stumpel

CSS Scroll-driven animations for Creative Developers

Scroll animations have been around for years and are pretty much synonymous with Creative Development, but they have always required JavaScript to record the scroll position. This brings up a myriad of issues when done incorrectly, like; lagging animations, heavy recalculations on resize, heavy feeling websites overall, unresponsive websites in slow internet environments and much more.

But even if you “do it well”, JavaScript still runs on the main thread, the scary place where browsers process user events, CSS repaints and reflows. We want to spend as little time there as possible because when the main thread is blocked by JS for example, it will delay or block other user interactions and make websites unresponsive. Moving scroll animations from the main thread to it’s own thread with the Scroll-driven Animations module in CSS makes it faster and more performant.

CSS scroll-driven animations are only supported in Chromium browsers at the moment, which is probably why many creative developers haven’t really looked into it yet. But, after using it on my portfolio (with GSAP Scroll Trigger backups), I’m sold.

Link to:

The basics

Scroll-driven animations rely on CSS keyframe animations, but in stead of time determining the progress of the animation it’s the scroll position of the user.

At this moment there are two types of scroll based animations; view- and scroll progress. In short: scroll progress timelines have start and end values that are based on the start and end position of the scrollable container (the body by default). View progress timelines are determined by the (linked) element’s position in the viewport. For my fellow GSAP enthousiasts; it’s similar to setting a trigger; that trigger can be the element you’re animating but also some other element on the page.

Both timelines have an animation-range property, which you can use to alter the start and end positions, although the current documentation only mentions view timelines in the examples and the effects are pretty different on both elements when using the same values.

If you’re familiar with GSAP’s ScrollTrigger, the animation ranges will probably take some getting used too. Bramus van Damme created this nice visualizer to play with.

But I still had to draw it out, to have it make sense to me, maybe it helps you too:

The different keywords for `animation-timeline` drawn out with their default start and end points
The different keywords for `animation-timeline` drawn out with their default start and end points

Here’s an example of a progress bar that’s triggered by the scroll() progress, and animates the scaleX value (animating scale causes no CSS repaints) from 0 to 1:

.progress-bar {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 5px;
	background: #ececec;
	.progress-bar__inner {
		width: 100%;
		height: 100%;
		background: hotpink;
		
		animation-timeline: scroll();
		animation-name: progress-bar;
		
		transform: scaleX(0);
		transform-origin: left center;
	}
}

@keyframes progress-bar {
  from {
		transform: scaleX(0);
  }
  to {
		transform: scaleX(1);
  }
}
CSS

Progress bars were popular on articles a few years back, the ‘problem’ (in my neurotic opinion) for this specific use case is that scroll() looks at the scrollable container for its start and end values, and will count elements like header, footer, related articles, etc as progress too.

In the example below I’m using the .content element (this element would only have the content of the article inside it) as the view timeline element, this element together with the animation-range property will now determine the start and end of the animation.

/* you have to define the view timelines in a parent component if you're not animating a direct child of the view timeline (https://developer.mozilla.org/en-US/docs/Web/CSS/timeline-scope) */
body {
  timeline-scope: --content; 
}

.content {
  view-timeline: --content;
}

.progress-bar {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 5px;
	background: #ececec;
	.progress-bar__inner {
		width: 100%;
		height: 100%;
		background: hotpink;
		
		animation-timeline: --content;
		animation-range: entry 100svh exit;
		
		animation-name: progress-bar;
		animation-fill-mode: forwards; /* So scaleX will remain 1 after the timeline is done */
		transform: scaleX(0);
		transform-origin: left center;
	}
}

@keyframes progress-bar {
  from {
		transform: scaleX(0);
  }
  to {
		transform: scaleX(1);
  }
}
CSS

Here’s a codepen where you can see the difference (toggle the checkbox):

See the Pen Progress bar two ways by Cyd Stumpel (@Sidstumple) on CodePen.

The entry 100lvh start value only starts the animation when an element hits the top of the screen, very useful for sticky elements too!

Link to:

Applied scroll animations

I’ve noticed that a lot of the examples created with Scroll-driven animations are not really things creative developers would use. I created a collection with use cases for creative Developers.

Link to:

Marquees:

See the Pen Scroll-driven marquees (CSS only) by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

Parallax animations

See the Pen Parallax scroll-driven animations – CSS only by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

Appear animations

See the Pen Appear scroll-driven animations – CSS only by Cyd Stumpel (@Sidstumple) on CodePen.

Link to:

JavaScript API

You can’t say Creative Development without saying Javascript, so thankfully, there’s ScrollTimeline for JavaScript, which hooks into the Web Animations API. I haven’t worked at all yet with WAAPI, but I hope to check this out soon.

Don’t forget to remove the animations for people who have the prefers-reduced-motion setting activated. Don’t know how? Check out the Codepens above for more information

Link to:

Graceful degradation

In the beginning of this article I mentioned that the scroll-driven animation module only works on chrome, we can implement the graceful degradation software philosophy here, backing the CSS animations up with JavaScript:

// checks if this CSS property + value is supported:
if (!CSS.supports('animation-timeline', 'view(y 100lvh 50px)')) { 
  // backup code here	
}
JavaScript

For example, this is how I backed up the title animations on this portfolio:

import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)

if (!CSS.supports('animation-timeline', 'view(y 100lvh 50px)')) {
		const scrollHeadings = [...document.querySelectorAll('.js-scroll-heading')]
		scrollHeadings.forEach(el => {
			gsap.to(el, {
  			scrollTrigger: {
  				trigger: el,
  				endTrigger: window.innerWidth > 1024 ? el : el.closest('section'),
  				start: 'top top+=24',
  				end: window.innerWidth > 1024 ? 'center top' : 'bottom top',
  				toggleActions: 'play none play reverse',
  				scrub: window.innerWidth > 1024 ? true : false, // remove scrub on mobile, because it's janky
  			},
  			duration: 0.3,
  			scale: window.innerWidth > 1024 ? 0.3 : 0.5,
  			ease: 'none'
			})
		})
	}
JavaScript

It’s good to wrap the CSS side in @supports tags too, to make sure the animation is only applied when animation-timeline is supported:

.scroll-heading {
  position: sticky;
  top: 0;
  z-index: 10;
  
  --scale: 0.3;
  
  // sidenote: this is my sass variable for tablet screens prints something like screen and (max-width: 1024px)
  @media #{$medium-down} { 
    --scale: 0.5;
  }
  
  span {
    display: block;
    transform-origin: top center;
    @media (prefers-reduced-motion: reduce) { 
      // if user prefers reduced motion: the title is small without the animation:
      transform: scale(var(--scale));
    }
    @media (prefers-reduced-motion: no-preference) { // only implement if prefers reduced is not set;
      @supports (view-timeline: --entry-0) { // only implement if view-timeline property is supported
        animation-timeline: view(y);
        animation-range: entry calc(100lvh - var(--padding)) entry 110lvh;
        animation-timing-function: var(--default-ease);
        animation-name: scale-out;
        animation-fill-mode: forwards;
      }
    }
  }
}

@keyframes scale-out {
  to {
    transform: scale(var(--scale));
  }
}
SCSS
Link to:

Resources and tools

Link to:

Did I miss something?

Do you see any mistakes or did I miss something important? Please let me know by sending an email!

Portrait of Cyd Stumpel, wearing a white t-shirt posing for the picture.

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

Last updated: January 26, 2025