Client Rects and Scroll Effects

Although it wasn't the first story to demonstrate the power of scrolling, the infamous Snowfall graphic remains the most notable of the genre for many journalists. The first time that readers scrolled the page and were suddenly immersed in a video of the landscape, rising up into the viewport, it was clear that this was a new storytelling tool that no newsroom could ignore. And it has only become more important as mobile has begun to dominate audiences.

In video game design, it's common to talk about the "verbs" that are available to the player. A simple game of Super Mario contains only a few verbs: run, jump, duck. The more verbs you add to your game, the more complex and interesting it can become, but the harder it is for players to learn. A modern simulation might have tens or even hundreds of verbs, depending on the player and the situation, and they spend a corresponding amount of time on player "education."

These principles are the same for interactive news design: readers have to learn the verbs for complex interactives, whether as simple as clicking a quiz button or complex as filling in a trend graph. There are many designers who argue that news graphics should be reduced, if at all possible, to as few verbs as possible—all the way to a static image, if necessary. But scroll, particularly on touchscreens, is one verb that we never have to teach. It's instinctive, and that makes it deeply useful for our purposes.

Registering efficient scroll listeners

Reacting to the page scroll is not hard to do. Both the window and various elements themselves will fire a scroll event, and so we can just listen for it:

window.addEventListener("scroll", function() {
  console.log("Scroll event fired!");
});

That said, if you run this code in the console, you'll quickly find that depending on the browser, you may soon be up to your ears in scroll events—way more than you actually need. Worse, if your code involves making a lot of changes to the page or doing a lot of processing, you can kill your scroll performance. Here's a quick demo:

Clicking the "Add delay" button will register a scroll listener that churns in a loop for about 300 milliseconds, while triggering repeated page layouts. That's not a very long time. But it's long enough to drop frames: scrolling through the page using the page up/down keys should be noticeably less smooth than with the delay removed. You won't be writing a pointless loop like this, of course, and your code probably won't take 100 milliseconds to do its work. But if you change the page on every scroll event (say, adding or removing a class from the body), you'll trigger the browser's layout process, and that can easily take enough time to become choppy.

Browser developers are aware of this, and so they've taken some steps to mitigate the effect. It's too late to change the behavior of keyboard scrolling, for example. But you may notice that on phones, or using a mouse or touchscreen, scrolling remains smooth, because those scroll listeners are triggered less frequently, without blocking viewport updates. This article from Microsoft has more detail on how these events are handled across different browser engines.

Regardless, you often don't need to take action on every single page scroll event. It's not only bad for your performance, but it also makes your effects feel overly-sensitive, and most of the time they just don't need to be that picky. To change this, we can throttle or debounce our scroll listener, so that it only actually runs a few times per second, no matter how often the scroll event fires. Utility functions to do this are included in many packages like Lodash, but a simple throttle is also easy to write:

var debounce = function(originalFn, delay = 150) {
  var timeout = null;
  // return a new function that's throttled
  return function(...args) {
    // if currently in the timeout, return
    if (timeout) return;
    timeout = setTimeout(() => timeout = null, delay);
    originalFn(...args);
  };
};

// example of use:
var throttled = debounce(function() {
  console.log("scroll");
});

// this will only log out every 150ms
window.addEventListener("scroll", throttled);

Our debounce function will run the submitted function on the initial call, but then it rejects further calls until the delay (defaulting to 150ms) expires.

Debouncing is a great way to handle scroll listeners that trigger large, imprecise changes to the page, like adding a control class or triggering a CSS animation. Most of the time, that's the use case you should have in mind. On many of our big projects, we may want to pan or zoom around a map in response to the user's position in the document. Since the pan itself is animated and takes most of a second, updating on every single scroll event is overkill, and the animation would stutter as it was constantly restarted.

That said, debouncing is inappropriate for fine-grained animations based on the scroll position. In those cases, your listener function itself will need to dump out as early as possible to keep from interfering with scroll behavior. For example, in this story on prostitution rings in Bellevue, WA, the animated sections are linked directly to the scroll distance, so a 150ms delay would have resulted in jerky transitions. Instead, each listener checks for the visibility of its designated section, and exits early to keep from performing expensive animation work on offscreen sections.

The magic of getBoundingClientRect

How do we figure out scroll position? It's tempting to do so using the scrollTop property of the document's containing element, and compare this to a range of offsets. At best, this will be slow. At worst, it's brittle and error-prone. A better method is to lean on the browser's layout engine, by defining scroll zones in your markup and checking their positions against the viewport.

Let's say that we want to create a parallax background effect, where scrolling through the page causes the background image to change. We might define each zone using a common class, and force it to be 150% of the screen height in CSS, so that it feels like a reasonable distance regardless of device size:

.scroll-zone {
  height: 150vh;
}

Then, in our scroll listener, we're going to check the position of each zone relative to the screen, using a function called getBoundingClientRect(). This function, which is incredibly useful as an interactive developer, just tells you the location and size of a given element. If the top of the element is greater than zero, but less than the height of the screen, it must be somewhere in the viewport.

var zones = $(".scroll-zone");

var listener = function() {
  zones.forEach(function(zone) {
    var bounds = zone.getBoundingClientRect();
    if (bounds.top > 0 && bounds.top < window.innerHeight) {
      var bg = zone.dataset.bg;
      document.body.style.background = bg;
    }
  });
};

window.addEventListener("scroll", debounce(listener));

This code works for these empty elements, which are basically just "placeholders" for scrollable space. But it'll also work if there's variable-length article content inside the scroll zones—just add padding to top and bottom if you need to space those out. This is particularly important on mobile, which may need different spacing to give the page a good rhythm.

One of the things that you'll notice with this code is that it has to check every single element, even if we've already found something in the viewport. If the scroll zones are short enough, this also means that multiple zones could be considered "active" (shown in these demos with a black outline).

Ideally, we should quit as soon as we locate an onscreen element, which prevents multiple "active" zones and doesn't waste valuable JS execution time. To do so, we'll switch from a functional loop, to a manual loop with a counter:

var listener = function() {
  for (var i = 0; i < zones.length; i++) {
    var zone = zones[i];
    var bounds = zone.getBoundingClientRect();
    if (bounds.top > 0 && bounds.top < window.innerHeight) {
      var bg = zone.dataset.bg;
      document.body.style.background = bg;
      //exit our loop
      break;
    }
  }
  // we can still run code every iteration here, if need be
};

Now our code is more efficient because it only checks as many positions as it needs to, but we'll still have a problem in any cases where there is more than one scroll zone in the viewport at a time.

Our code exits after finding the top-most fully-visible element, but this means we're not technically checking for when elements become visible so much as when their predecessor leaves the viewport. The resulting interaction feels like it has a delay—users won't be able to predict where the line is for triggering a scroll effect.

The most "natural" behavior for a browser is to react as an element comes into view at the bottom, even if there's another item onscreen. An easy solution is just to reverse the direction of our loop: instead of checking elements for visibility starting at the top and working down, we'll start at the bottom and work up. We'll also raise the bottom boundary for being considered visible, to a constant 80% of the viewport. This creates a consistent "visibility zone" for scroll effects, and makes sure we react instantly once they enter that zone from the bottom (which is, of course, the most common scroll direction).

var listener = function() {
  // backward loop here
  for (var i = zones.length - 1; i >= 0; i--) {
    var zone = zones[i];
    var bounds = zone.getBoundingClientRect();
    // note the .8 - ignore the bottom 20% of the window
    if (bounds.top > 0 && bounds.top < window.innerHeight * .8) {
      var bg = zone.dataset.bg;
      document.body.style.background = `url(${bg})`;
      //exit our loop
      break;
    }
  }
};

I should note that although this approach is battle-tested and reasonably-performant, it still involves an expensive layout check for multiple elements, and it doesn't work at all inside an embedded iframe like Pym.js. The future of visibility checks is with IntersectionObserver, in which the browser will notify you for multiple observed elements as a whole batch, including a near-identical bounds object, following a single layout operation. I suspect that for many news applications, getBoundingClientRect() will remain useful, but if you're starting a new scroll project and don't mind abandoning some older browsers, it might be worth trying Intersection Observers instead.

Useful snippets for scroll-aware pages

Given a bounds object from getBoundingClientRect(), what are some common applications?

Viewport scissor test

Is something completely in the viewport? This is as easy as checking the top and bottom against the window.

if (bounds.top > 0 && bounds.bottom < window.innerHeight) {
  return true;
}

Exclusion scissor test

It's also useful sometimes to know if something isn't in view. For example, on WebGL projects, I often skip rendering if you can't see the canvas anyway, so I don't burn power for no good reason.

if (bounds.bottom < 0 || bounds.top > window.innerHeight) {
  return true;
}

Activating sections once

A common task is to cause an effect to trigger an effect only once per section, per page load. Using an array filter is a simple way to do this, since it actually just removes the element from the bounds check. With a little extra work, we can even kill the listener when we're out of single-use elements.

//elements is an array of scroll zones
var scrollCheck = function() {
  elements = elements.filter(function(el) {
    // require it to be halfway onscreen
    if (bounds.top > 0 && bounds.top < window.innerHeight * .5) {
      el.classList.add("activated-scroll");
      // remove from the array
      return false;
    }
    // keep it otherwise
    return true;
  });
  // remove listener when everything is activated
  if (!elements.length) window.removeListener("scroll", scrollCheck);
};

window.addEventListener("scroll", scrollCheck);

Measuring progress

Many scroll-linked animations require you to know how far you've scrolled through any given element—meaning, between the element entering at the very bottom of the screen and leaving at the top, what's the progress between those two points? Parallax scrolls often rely on this information. To do so, we'll find the normalized delta as a number between 0 and 1, where 1 is "leaving the top" and 0 is "entering at the bottom."

var vh = window.innerHeight;
// what's the total covered area?
var total = vh + bounds.height;
// how far have we gotten through that?
var scrolled = bounds.top - vh;
// normalize to a 0...1 range
var progress = -scrolled / cover
// you can also convert to -1...1 for some effects
var viewRelative = progress * 2 - 1;

Home: stuck

The fastest code is the code we never run, and nowhere is that more true than when it comes to reacting to page scroll. For some use cases, it's increasingly possible to create effects purely in CSS.

Take the persistent menubar, for example. On many pages, you may want a menu to be a part of the normal page layout, but to adhere to the viewport as the page scrolls. It's tempting to do this as a JavaScript page effect, but doing so will often cause choppy scrolling, because it requires multiple layouts to check and update the position of the menu element:

var onScroll = function() {
  var bounds = menuContainer.getBoundingClientRect();
  // is the menu's containing element offscreen?
  if (menuContainerBounds.top < 0) {
    // adhere to viewport
    menu.classList.add("fixed-position");
  } else {
    // normal document flow
    menu.classList.remove("fixed-position");
  }
};

A simpler solution is just to let the browser handle the fixed positioning by setting the menu's CSS to position: sticky. Supported in pretty much everything except for IE, this leaves the element in place until it hits the boundary defined by top or bottom styles, and then locks it as though it was position: fixed. In IE, the menu won't fix, which is unfortunate but does at least fulfill the principles of graceful degradation. Sticky elements are also limited to the bounds of their containers, which means they can easily slide offscreen once you scroll past a given point. It's a long overdue part of the CSS toolkit.