Editor’s Note: This post was updated June 2021 to include the latest information on IntersectionObserver
V2.
If used incorrectly, 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 IntersectionObserver
solution we detail below.
The latter is likewise a suitable long-term solution with significant adoption across major browsers, covering up to 97% of users. However, its exact specifications undergo minor refactoring periodically, leaving 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 and widely supported properties in use, and upcoming properties.
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.
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:
null
, or any other DOM element the developer specifies.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);
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, using a supported browser, 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, IntersectionObserver
tells you when an item is fully present in the viewport but not if the item is actually visible. For example, the item may be fully transparent or occluded, resulting in imprecise statistics for ad exposure.
IntersectionObserver
V2Similarly to the first version of IntersectionObserver
, IntersectionObserver
V2 will invoke a callback whenever the target overlaps with the viewport. Unlike before, V2 now features two additional configurations, with descriptions below taking nearly verbatim from the W3 spec for its interface:
trackVisibility
: if true, track changes in the target’s visibility. Tracking visibility is more expensive than tracking intersections with the viewport, so webpage performance can degrade significantly with this enabled. As a result, this V2-only option should be used only when necessary. A warning to this effect appears if you set visibility tracking without specifying a valid delay.
delay
: minimum number of milliseconds between observer notifications, with a minimum of 100ms if trackVisibility
is true.
To see V2 in action, set the two configuration options above when initializing your IntersectionObserver
. The returned IntersectionObserverEntry
will have a new boolean field, isVisible
, that you can then use.
The following is a sample intersection observer configuration. There are two sample boxes: one partially see-through, and the other is solid colored. Any time the solid-colored box is fully visible in the user viewport, the handler is triggered. Most of the below code is reproduced from above, with the few new lines commented:
var intersectionOptions = { root: null, rootMargin: '0px', threshold: 1.0, trackVisibility: true, // NEW delay: 100, // NEW } function intersectionCallback(entries, observer) { entries.forEach(entry => { if ( entry.intersectionRatio >= 1 && entry.isVisible // NEW ) { console.log(entry.target.id + " fully visible!"); // NEW } else { console.log(entry.target.id + " not fully visible!"); // NEW } }); } var observer = new IntersectionObserver(intersectionCallback, intersectionOptions); var targets = document.querySelectorAll('.box'); // NEW - id -> class selector + select all targets.forEach((target) => { // NEW - iterate over all boxes observer.observe(target); });
The HTML and CSS is also mostly reproduced, with just a few lines of change.
<html> <head> <title>IntersectionObserver Demo</title> <style> .box { # NEW - change from id to class selector margin-top:100% 0 100% 0; width:100%; height:90%; background-color:black; } .transparent { # NEW - new class for transparent box opacity: 0.5; } </style> </head> <body> <div id=”box1” class="box"></div> <!-- NEW: change from id to class selector --> <div id=”box2” class=”box transparent”></div> <!-- NEW: half-transparent box --> </body> </html>
View the codepen here, using a supported browser, and try scrolling the boxes fully into view and then partially or completely out of view. Whenever the solid-colored box is fully scrolled into view, the console reads Box 1 fully visible!
. Whenever the box is scrolled so that it’s no longer fully in view, the console reads Box 1 not fully visible!
. Note that regardless of the transparent box’s presence in the viewport or not, you will also always see Box 2 not fully visible!
.
Although this gives us a visibility-robust variant of discrete scrolling events, this does not encompass scroll-linked animations that occur continuously as the user scrolls. One example is the 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.
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.
Several common scrolling effects are possible using browser-optimized CSS properties: scroll snapping is already supported for major browsers, covering 93.7%+ of users; sticky position is supported for 95.5%+ of users; and a makeshift CSS parallax is supported by 98.4%+ 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.
First, the spec for CSS snap scrolling is in flux. There are a number of deprecated CSS properties to avoid, including the following:
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:
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.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.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.scroll-snap-stop
Note that this property is considered “at-risk” by the W3C 2021 Editor’s Draft, meaning it may or may not be deprecated in the future.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.
Scroll snapping is just one of many scroll-linked animations, but with its 93.8%+ coverage of the user base and continued rapid rise in support, it may be ready for prime time with your application.
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 February 2021 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.
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.
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
One Reply to "How to use scroll-linked animations the right way"
This article saved my life! I was struggling with scroll events and I didn’t even know the existence of the Intersection Observer API… This made everything so much simpler. Thank you so much!