Thanks to visit codestin.com
Credit goes to www.bram.us

Combining Scroll-Driven Animations with @starting-style

Recording of the demo, recorded in Google Chrome.

How the cascade, the animation-fill-mode, and implicit keyframes make things a bit more complicated then you’d initially think …

~

The ask

On Bluesky, Ryan asked:

Is there any way to combine scroll-driven animations with starting-style? e.g. to have something fade-in on page load with starting-style, but then have its scroll container apply an animation which operates on opacity?

I was quick with my reply to say that should work, but when trying it out later when I was back at my computer … it turned out to be a bit more complicated than I thought.

~

A first attempt

To get started quickly, I forked the following demo by Adam:

The demo sets a View Timeline on a list of elements that all animate in nicely.

@keyframes slide-fade-in {
  from {
    opacity: 0;
    box-shadow: none;
    transform: scale(.8) translateY(15vh);
  }
}

.card {
  animation: slide-fade-in both;
  animation-timeline: view();
  animation-range: contain 0% contain 50%;
}

🤔 Unfamiliar with Scroll-Driven Animations? Go check out my free video course to learn all about them.

Simply adding @starting-style like so didn’t cut it:


.card {
  /* Have the opacity over a duration of 2500ms */
  transition: opacity 2500ms ease;

  /* Use starting-style to transition opacity from 0 to 1 */
  opacity: 1;
  @starting-style {
    opacity: 0
  }
}

The reason for this, is that animations are part of a higher origin in the CSS Cascade. And because the animation-fill-mode is set to both, the animation applies its styles to its target even while the animation is not active. Then there’s also an implicit to keyframe at play here, whose value for opacity would end up being 1. As per css-animations-1 spec:

If a 0% or from keyframe is not specified, then the user agent constructs a 0% keyframe using the computed values of the properties being animated. If a 100% or to keyframe is not specified, then the user agent constructs a 100% keyframe using the computed values of the properties being animated.

So why 1 in that implicit to keyframe and not the intermediate value of opacity as it transitions? Well, that’s gotten me scratching my head right now because the css-transitions spec reads:

The computed value of a property transitions over time from the old value to the new value. Therefore if a script queries the computed value of a property (or other data depending on it) as it is transitioning, it will see an intermediate value that represents the current animated value of the property.

Script clearly can see the intermediate value, but somehow CSS itself can’t? I’m pinging some folks from Blink engineering about this. Perhaps we need something like “transition tainted” in CSS?

~

The custom property indirection

Anywho, the trick I ended up doing was to transition a (registered) custom property from 0 to 1, and use that as the opacity as the target value in an explicit to keyframe of the animation.

/* Register the custom property so that it can nicely transition/animate */
@property --loaded {
  syntax: "<number>";
  initial-value: 0;
  inherits: false;
}

/* The keyframes for the scroll-driven animation. Note that the opacity gets set to var(--loaded) in the to keyframe */
@keyframes slide-fade-in {
  from {
    opacity: 0;
    box-shadow: none;
    transform: scale(.8) translateY(15vh);
  }
  to {
    opacity: var(--loaded);
  }
}

.card {
  /* Have the custom prop transition over a duration of 2500ms */
  transition: --loaded 2500ms ease;

  /* Use starting-style to transition the prop from 0 to 1 */
  --loaded: 1;
  @starting-style {
    --loaded: 0;
  }

  /* The scroll-driven animations code */
  animation: slide-fade-in both;
  animation-timeline: view();
  animation-range: contain 0% contain 50%;
}

While the custom property transitions, the to keyframe constantly gets recomputed because the value of the custom property changed.

The result can be seen in this demo:

Note: Checking this in other browsers (both Safari and Firefox) I see it’s not entirely working as expected. Both do things differently, so there’s not really any consensus amongst browser. I didn’t have time to dig in yet, but I do think Chrome shows the behavior I’d expect.

~

Minor tweaks

With the solution available I also made a reverse version that animates element out as you scroll – which is the thing Ryan initially requested.

One more trick I also used in the demo is a little stagger animation using sibling-index(). However, it’s not using sibling-index() just blindly: With 138 elements in the list, the last element would have a transition-delay of 13800ms, so you wouldn’t see items way at the bottom of the page until after almost 14 seconds!

To limit the delay to only the first 10 items I didn’t use some selector magic that only targets those items but, instead, I am using min() to limit the max delay to 10 * 100ms.

.card {
  transition-delay: calc(min(sibling-index(), 10) * 100ms);
}

~

🔥 Like what you see? Want to stay in the loop? Here's how:

I can also be found on 𝕏 Twitter and 🐘 Mastodon but only post there sporadically.

Published by Bramus!

Bramus is a frontend web developer from Belgium, working as a Chrome Developer Relations Engineer at Google. From the moment he discovered view-source at the age of 14 (way back in 1997), he fell in love with the web and has been tinkering with it ever since (more …)

Unless noted otherwise, the contents of this post are licensed under the Creative Commons Attribution 4.0 License and code samples are licensed under the MIT License

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.