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
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…
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
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%orfromkeyframe is not specified, then the user agent constructs a0%keyframe using the computed values of the properties being animated. If a100%ortokeyframe is not specified, then the user agent constructs a100%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);
}
~
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 …) View more posts