Scroll-Driven Animations are controlled by scroll: as you scroll up and down, the animation will scrub forwards and back, in direct response. But what if you want a scroll-driven animation to stay on its endframe once it was entirely played? Let this little piece of JavaScript help you out …
~
# The Code
If you’re here for just the code, you can import runOnce
from the @bramus/sda-utilities
package.
import { runOnce } from '@bramus/sda-utilities';
// Run the “fade-in” scroll-driven animation on the `#hero` element only once.
window.addEventListener('load', (e) => {
const $hero = document.querySelector('#hero');
runOnce($hero, 'fade-in');
});
To see this snippet in action in, go check out the demo.
The source code of the runOnce
function itself is available on GitHub. You can check the package details on NPM.
~
# How to use
Call the runOnce
function and pass in a reference to an element along with the name of the animation you want to run only once:
document.querySelectorAll(".photo").forEach(($photo) => {
runOnce($photo, "animate-in");
});
The passed in animation name – here animate-in
– is optional. When not passing in a name, all Scroll-Driven Animations attached to that element will only run once.
☝️ In theory, you can run this code from the moment the elements you are targeting are available in the DOM. Unfortunately there is a bug in Chrome 115-117 where Scroll-Driven Animations might trigger rogue animationend
events while the timeline is still being calculated. The bug is fixed in Chrome 118.0.5993.11, but for earlier versions you need a small workaround.
Thankfully the workaround is pretty simple: only attached the event listeners after the window
’s load
event has been triggered.
window.addEventListener("load", (e) => {
document.querySelectorAll(".photo").forEach(($photo) => {
runOnce($photo, "animate-in");
});
});
~
# How it works
At its core, the code listens for the animationend
event on the passed in element. From that element, the relevant animation is filtered out and then stopped.
$el.addEventListener('animationend', (e) => {
const animation = animations.find((a) => a.animationName == e.animationName);
if (shouldAnimationBeStopped(animation, animationName)) {
animation.commitStyles();
animation.cancel();
}
});
To effectively stop the animation, the computed values of the animation’s current styles are written to the element using animation.commitStyles();
before animation.cancel();
ever gets called.
To prevent glitches upon removal, it’s best to set the animation-fill-mode
to forwards
or both
. If you’ve not done this, the function will give you a warning on the Console that glitches might occur in this case.
☝️ The way the animation gets filtered from the element’s list of animations works in all commmon scenarios but will go wrong when you’ve got multiple animations that have the same name attached. As a workaround, use unique animation names for each animation on an element.
As per recent CSS WG Resolution, the animation-*
events have been updated to also get the actual Animation
object passed in, fixing this problem. At the time of writing, however, this is not implemented by any browser. The Chromium bug tracking this adjustment is https://crbug.com/1479139.
~
# Demo
You can find a demo on CodePen:
See the Pen Run Scroll-Driven Animations only once by Bramus (@bramus) on CodePen.
To make it very clear when the animation was finished, a lime
border is drawn around each subject that has reached that state.
~
# Spread the word
To help spread the contents of this post, feel free to retweet its announcement tweet:
New blogpost: “Run a Scroll-Driven Animation only once”
A small piece of JavaScript to run a scroll-driven animation only once.
🏷️ #css #js #ScrollDrivenAnimations
— Bram.us (by @bramus) (@bramusblog) October 5, 2023
~
🔥 Like what you see? Want to stay in the loop? Here's how:
“Play Once” icon from the Phosphor Light Vol.4 icon pack