Run a Scroll-Driven Animation only once

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)) {


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


# 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:


“Play Once” icon from the Phosphor Light Vol.4 icon pack

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.