Alvin Wan PhD in artificial intelligence at UC Berkeley, focusing on small neural networks in perception for autonomous vehicles; big fan of cheesecake, corgis, Disneyland

How to do scroll-linked animations the right way

6 min read 1956

If not used correctly, scroll-linked animations suffer from lower frame rates and apparent jitter due to inefficiencies in implementation. Even the 2014 hot topic, the parallax scrolling effect, met its fair share of inefficiencies. Frederick et al (2015) reported “significant usability issues” and even motion sickness from websites with parallax scrolling. Animations are not always to blame, because even sans effects or improper scroll event listeners still create headaches. Twitter responded to a rash of issues with laggy scrolling in 2011, leading the creator of jQuery John Resig to conclude expensive scroll event handlers are a bad idea.

This sparked two general classes of responses: Javascript monkey patches and CSS substitutions, to leverage browser optimizations. The former is but a workaround, with a longer-term IntersectionObserversolution we detail below. The latter is likewise a suitable long-term solution but is currently undergoing major refactoring, seeing many deprecated  scroll-snap-* CSS properties.

This article revolves around both classes of responses, with two takeaway tips: use Javascript observer patterns and use CSS where possible. We start with JavaScript workarounds and solutions. We then discuss CSS properties and how they can be used to simplify scroll-linked animations — deprecated properties to avoid, current properties in use, and upcoming properties.

Use the JavaScript observer pattern where possible

Scroll-linked animations are often based on scroll position. For such applications, scroll event listeners are polling for scroll position — if scroll position is within range of a target, perform a scroll-linked animation. This is contrary to Javascript’s design methodology, which is to listen for events rather than poll for events. Analogously, scroll-linked animations would listen for scroll position rather than check scroll position. The observer pattern, specifically IntersectionObserver is a durable technique that accomplishes just this. Roughly speaking, once a certain scroll position has been reached, the IntersectionObserver event handler is executed. We describe in more detail and more precisely what it accomplishes below.

Discrete scrolling effects with IntersectionObserver

The Intersection Observer API works on a per-element basis. Whenever that element, known as the target, appears on the screen, the Intersection Observer callback is invoked. There are three configuration options to note:

  1. To be specific, the API supports more than the screen. Whenever the target element intersects with another element, the root, the callback is invoked. The root may be the screen, which is specified using null, or any other DOM element the developer specifies.
  2. The API allows the developer to specify what “appear” means, whether it means the target barely intersects with the root or if it completely intersects with the root. The intersection ratio is the percentage of the target visible within the root. 0 means the target is not visible and 1.0 means the target is fully visible; the developer can specify a threshold for this intersection ratio, or an array of thresholds. Each time the intersection ratio passes a threshold in the array, the callback is invoked. Note both directions will trigger the callback — either starting below and ending above the threshold or vice versa.
  3. To calculate intersection, the API uses the smallest rectangle containing the visible portion of the target and the smallest rectangle containing the root’s content. The root margin is added around the root content’s containing rectangle, for calculating intersections. See MDN’s “How intersection is calculated” for details.

The following is a sample intersection observer configuration. Anytime the sample box is fully visible in the user viewport, the handler is triggered:

var intersectionOptions = {
  root: null,  // use the viewport
  rootMargin: '0px',
  threshold: 1.0
}

function intersectionCallback(entries, observer) {
  entries.forEach(entry => {
    if (entry.intersectionRatio >= 1) {
      console.log("Fully visible!");
    } else {
      console.log("Not fully visible!");
    }
  });
}

var observer = new IntersectionObserver(intersectionCallback, intersectionOptions);

var target = document.querySelector('#box');
observer.observe(target);

function intersectionCallback(entries, observer) {
  entries.forEach(entry => {
    if (entry.intersectionRatio >= 1) {
      console.log("Fully visible!");
    } else {
      console.log("Not fully visible!");
    }
  });
}

var observer = new IntersectionObserver(intersectionCallback, intersectionOptions);

var target = document.querySelector('#box');
observer.observe(target);

The following is the corresponding HTML and CSS:

<html>
  <head>
    <title>IntersectionObserver Demo</title>
    <style>
      #box {
        margin-top:100% 0 100% 0;
        width:100%;
        height:90%;
        background-color:black;
      }
    </style>
  </head>
  <body>
    <div id="box"></div>
  </body>
</html>

View the codepen here, and try scrolling the black box fully into view and then partially or completely out of view. Whenever the box is fully scrolled into view, the console reads Fully visible!. Whenever the box is scrolled so that it’s no longer fully in view, the console reads Not fully visible!.

However, this does not encompass scroll-linked animations that occur continuously, as the user scrolls. One example is a parallax effect. Several workarounds exist if a scroll event handler is necessitated, in these scenarios. These methods generalize to all high-frequency event handlers and focus on reducing the number of times a handler is invoked.

Continuous scrolling effects with debouncing and throttling

Debouncing focuses on “bursts” of events. For any quick sequence of event handler invocations, either (a) execute the first time and ignore all immediate successive calls or (b) wait until the “last” call and execute afterwards. The former is called trailing mode. The latter is called the leading (or “immediate”) mode.

This has immediate applications with window resizing, drag-and-drop, and preview rendering. However, this has adverse implications for scroll-linked animations. This pitfall of debouncing is its definition of a burst: all consecutive calls within wait milliseconds are clumped into a burst. This means that as long as events occur within wait milliseconds, a burst can last indefinitely. With trailing mode, this means the event handler is never triggered. In other words, your website’s content may never be animated onto the webpage, if the user keeps scrolling.

Throttling focuses on the rate of execution and resolves the above pitfall. When an event is invoked repeatedly, the event handler is guaranteed to be executed every wait milliseconds. This has immediate applications with autocomplete in search. However, this method’s pitfall is where debouncing thrives — if a burst starts and ends within wait milliseconds, that burst will not trigger the event handler. For example, if wait is 1000ms second, and the user takes 500ms to scroll past Section A of the webpage, Section A will never run its scroll-linked animation. With the right combination of display and position misfortunes, this could offset the parallax effect adversely.

Admittedly, the above adversarial example is contrived, but this is to illustrate the complementary nature of both throttling and debouncing. Picking a method is choosing a tradeoff, and neither method entirely trumps the other. See articles from CSS Tricks’ Chris Coyier and [guest author David Corbacho for more details, along with the possibility of requestAnimationFrame as a third alternative. As we discuss in the CSS section below, these continuous scrolling effects are either currently or will be replaceable by browser-optimized CSS properties.

Use CSS substitutions where possible

Several common scrolling effects are possible using browser-optimized CSS properties: scroll snapping is already supported for major browsers, covering 80%+ of users; sticky position is supported for 90%+ of users; and a makeshift CSS parallax is supported by 97%+ of users [1 ,2]. However, even including such CSS properties with partial user base coverage, support is limited given the wide variety of use cases documented by W3C. Nevertheless, CSS substitutions unload the overhead of implementation and optimization onto the browser. With a Javascript or animation-less fallback, CSS properties can boost usability for a large majority of users.

Snap scrolling

First, the spec for CSS snap scrolling is in flux. There are a number of deprecated CSS properties to avoid, including the following:

  1. scroll-snap-coordinate
  2. scroll-snap-destination
  3. scroll-snap-points-x
  4. scroll-snap-points-y

These properties are in widespread use for CSS scroll-linked animation tutorials from webkit and CSS Tricks and are supported by Firefox and Safari. However, unlike most CSS design philosophies, snap positions are defined globally instead of per-element. The current spec for CSS properties differs and more strictly adheres to the per-element design philosophy:

  1. scroll-snap-align: [none | start | end | center] [none | start | end | center] Specify snap alignment for both the x and y directions. If only one value is specified, that option is applied to both directions. Google Web Developers illustrates this alignment and provides a carousel example.
  2. scroll-snap-type: none | mandatory | proximity The none option is straightforward, and the mandatory option is what you may expect from snap scrolling, where all scrolling positions snap. However, proximity only snaps if the user has scrolled to a position within the vicinity of a snap scroll position. Otherwise, the scroll is unaffected.
  3. scroll-padding: <length> | <percentage> This configures padding for the scroll area and behaves like typical padding properties. There is an analogous scroll-margin property. This is not specific to snap behavior but avoids issues like a sticky header obscuring content.
  4. scroll-snap-stop This property is denoted experimental and not implemented by any of the major browsers. However, it effectively denotes whether or not the user can “skip” a snap position.

However, snap positions are now denoted by elements with the above properties. Below is a sample snap scrolling demonstration in HTML and CSS, without a polyfill.

<html>
  <head>
    <title>Scroll Snapping Demo</title>
    <style>
      html, body, section {
        width:100%;
        height:100%;
      }
      body {
        scroll-snap-type: y mandatory;
      }
      section {
        scroll-snap-align: start;
      }
      section.black { background-color: black; }
    </style>
  </head>
  <body>
    <section></section>
    <section class="black"></section>
    <section></section>
    <section class="black"></section>
    <section></section>
  </body>
</html>

You can view the codepen here. Try scrolling up and down to see the scroll snapping in action. Per caniuse.com, make sure to use a supported browser for the new CSS scroll snap spec (as of writing — Chrome, Safari, or Edge).

Scroll snapping is just one of many scroll-linked animations, but with its 80%+ coverage of the user base and continued rapid rise in support, it may be ready for prime time with your application.

Other scroll-linked animations

To support continuous effects, concurrent with scrolling behavior (e.g., items move horizontally by x% when the user has scrolled down the page by x%), the W3C features an unofficial spec from March 2019 outlining potential CSS implementations. Needless to say, these are properties to look out for but not to use, as of writing. However, proposals akin to this one hold the promise of further offloading scroll-linked animation work to the browser.

Takeaways

In a nutshell, for scroll-linked animations, use CSS properties where possible, but where necessary, use the proper optimizations in Javascript: for discrete events based on position, use the observer pattern. For continuous events, rate-limit based on time or event bursts. Failure to do so can result in decrepit usability experiences, hindered and not bolstered by scroll-linked animations. For more details and additional resources, see the associated articles on Google Web Developers, CSS Tricks, and MDN web docs.

Plug: , a DVR for web apps

LogRocket Dashboard Free Trial Banner

 is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.

Alvin Wan PhD in artificial intelligence at UC Berkeley, focusing on small neural networks in perception for autonomous vehicles; big fan of cheesecake, corgis, Disneyland

Leave a Reply