Interactivity on the web has become an ever-increasing request for developers, as it has shown to increase user engagement and let’s face it; there’s a “cool factor.” Arguably, the most accessible and popular form of this type of UX is the “on scroll event.” Aside from the animation opportunities, there are optimization techniques that can be deployed when we know where the user is, such as delaying images from loading until the user approaches them (Lazy Loading).

To accomplish this task has been somewhat difficult to get right across the board for all browsers and devices, and the quickest way was to use a package like AnimateOnScroll or Waypoints, both of which are fantastic libraries with robust features and great cross-browser compatibility. As time went on though, browser functionality, alongside the ever-evolving landscape of Javascript, has given birth to more native ways of doing things that used to require these packages. The IntersectionObserver API was developed to accommodate something that is now an ubiquitous aspect of almost all websites: the ability to detect where a user is in relation to the document.

We’re going to provide a brief overview of how IntersectionObserver works, and how we can use native browser features to create a lightweight vanilla javascript alternative to these on scroll event libraries.

Part One: Setting up the IntersectionObserver

The IntersectionObserver API is surprisingly succinct to understand and deploy. In plain English, the API is saying:

“I want you to observe (watch) certain elements, in relation to the root element, as the user scrolls. When the element intersects with the root, then “do something.”

Let’s tease that apart and see what it looks like in code.


1) Define the Elements that We Want to Observe:

let observedElements = document.querySelectorAll('.inview-element');

(Note: this can also be a single selector but in our use case we almost always have multiple elements we need to observe, which will serve as the context from which we build our examples around.)


2) Set Our Options:

const options = { 
  root: document.querySelector('#MY_ROOT_ELEMENT'), //optional, defaults to browser window if excluded
  threshold: 0.5, //defaults to 0
  rootMargin: '0px', //default value, can also be excluded
}

The documentation explains what these options can do, but I’ll attempt to describe it here a bit more succinctly:

Root – Define the element we want to use as the main element for observing. This defaults to the “browser window” but can be an element within the document. In other words, we can define a section or element within the document to be the place where we want to observe intersections, e.g. a parallax section within the page.

RootMargin – This allows us to grow, or shrink, the size of the Root element. Think of it like CSS Margin: it takes positive and negative values, a single value can be provided, or a string with “sides”: top, right, down, left. For example, if we set the bottom value to “-50%” (using the viewport as the root), then the intersection will happen around the middle of the page.

Threshold – Defines what amount of the element we’re observing that needs to be in the viewport before triggering the intersection. Values are 0 to 1 (0% to 100% of an element, respectively).

(This IntersectionObserver Visualizer is very handy to play with and should provide a more tangible example)

A visual example of the IntersectionObserver options


3) Write Our Callback (actions we want to happen)

Ok, we know what we’re observing. We know the parameters we want to use around how it’s being observed. Now let’s define what actions we want to run when the “intersection” happens. Do we need to add a class? Show/hide an element? Execute a function? Anything we need to do, happens within this callback:

const inViewCallback = entries=> {
  entries.forEach(entry => {
    if (entry.isIntersecting) { // define the event/property you want to use
      //do something with the element, in our case, add a class 
      entry.target.classList.add('inview');  
   }
    else { 
      // OPTIONAL, in case you want to do something once the intersection is done
   }
  });
}

It should be noted that the “isIntersecting” option, while arguably the most useful and utilized, is only one of the IntersectionObserverEntry properties! There are others such as “intersectionRatio” which returns the difference between the intersection and the element, and “time” which returns the time at which the intersection was recorded.


4) Create an Instance

Up until this point, nothing is being observed yet, but we have everything prepped to do so. First up, we create a new instance using our callback which contains our elements and actions, using the options we defined.

let observer = new IntersectionObserver(inViewCallback,options); 

5) Run the Observer

And finally, we run the Observer on all the elements we defined from our original selector:

observedElements.forEach(function(element){
  observer.observe(element) // run the observer 
});

Here’s the full code, along with a CodePen example:

let observedElements = document.querySelectorAll('.inview-element'); // Define the elements you want to intiate an action on

const options = { //define your options
  threshold: 0.5
}

const inViewCallback = entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) { // define the event/property you want to use
      //do something with the element, in our case, add a class 
      entry.target.classList.add('inview');  
   }
    else { 
      // OPTIONAL, in case you want to do something once the intersection is done
   }
  });
}

// create a new instance using our callback which contains our elements and actions, using the options we defined
let observer = new IntersectionObserver(inViewCallback,options); 

observedElements.forEach(element => {
  observer.observe(element) // run the observer 
});

Now we have the basics, so let’s move onto how we can combine this with CSS transitions to create some cool on-scroll animations.


Part Two: CSS Animations

Once we know that an element is in the user’s viewport, this is where the fun begins. We can choose to execute certain function such as Lazy Loading images, or in our case, let’s animate content in as the user approaches.

1) Create some CSS Classes

First we’ll create a class that we can apply to any element to indicate it will be animated:

.animated {
  opacity: 0;
  visibility: hidden;
  transition:800ms cubic-bezier(.23,1,.32,1);
  &.inview { // animate the element in
    opacity: 1;
    visibility: visible;
    transform:translate(0);
  }
}

Now we can get creative and add some classes that will extend the initial state of the element. Since we set the class to always transform back to translate(0), we can create a range of effects pretty easily. If you need some ideas on what to do here, Animista can provide some inspiration.

.fadeInUp {
  transform:translateY(50px);
}

.fadeInDown {
  transform:translateY(-50px);
}

.fadeInLeft {
  transform:translateX(25px);
}

.fadeInRight {
  transform:translateX(-25px);
}

.turnIn {
  transform:scaleX(-1);
  transform-style: preserve-3d;
  backface-visibility: hidden;
}

.zoomIn {
  transform:scale(1.3);
}

2) Apply to the Markup

<div class="inview-element animated fadeInUp">
  <h2>Fade In Up</h2>
</div>

<div class="inview-element animated fadeInDown">
  <h2>Fade In Down</h2>
</div>

<div class="inview-element animated fadeInLeft">
  <h2>Fade In Left</h2>
</div>

<div class="inview-element animated fadeInRight">
  <h2>Fade In Right</h2>
</div>

<div class="inview-element animated turnIn">
  <h2>Turn In</h2>
</div>

<div class="inview-element animated zoomIn">
  <h2>Zoom In</h2>
</div>

Now every element that we’ve applied these classes to, will fade in:

This is looking good…but we can extend it just a bit further!


3) Optional: Staggered Animations/Transition Delays

This is great to animate elements in as the user scrolls, but what if we wanted some simple staggers? We can apply a transition-delay in our CSS/SCSS, but that can quickly become cumbersome and produce quite a bit of CSS bloat. It would be great to dictate that right in the markup to keep things a bit DRY, and then we can even do some cool tricks, like setting a counter and automatically generating the delays.

We’re going to lean on HTML5’s data attribute to accomplish this:

<div class="inview-element animated fadeIn" data-delay="300"> // adding a data attribute
  <h2>Fade In</h2>
</div>

We can extend our forEach that wraps our Observer:

observedElements.forEach(element => {
  let dataDelay = element.getAttribute('data-delay'); // get the delay integer 
  element.style.transitionDelay = dataDelay+'ms'; // apply an inline transition delay in milliseconds
  observer.observe(element) // run the observer 
});

Now we can have staggered animations easily set through the markup:


Bonus: Graceful Degradation for Absence of Javascript
(and JS errors)

We wouldn’t be good stewards of the internet if we didn’t account for what happens when JS doesn’t execute; whether the client has it turned off or if there is a JS error on the page. As it stands, it would cause our animated content to be hidden by CSS, with the browser unable to animate these elements in and creating blank gaps and pages.

We can get around this easily by using some vanilla JS to loop through any elements that we are observing, and add the class which would prep (hide) them for animation:

observedElements.forEach(entry => { // can also be added to our function from above
  entry.classList.add('animated');
});

One downside here is that depending on what we are animating, if it is higher on the page itself, we might see the content briefly before it’s hidden. Alternatively, we can place something similar high in our page markup to mitigate that from happening:

let animatedElements = document.querySelectorAll('.animated');
animatedElements.forEach(entry => {
    entry.className  += " animated";
});

And there we have it…

A lightweight, native, completely dependency-free way of doing simple scroll events and animations!


Final Consideration: Supporting Legacy Browsers

We would be remiss if we didn’t mention that the IntersectionObserver API, while gaining in browser support over time, still has some gaps:

Support is growing, but even browsers as recently released in 2017/2018 don’t support it

If we choose to proceed with deploying IntersectionObserver on a production site, we should take extra care in ensuring that our functionality remains intact on these older browsers. Depending on how we want to build in legacy support, there are two avenues we can take:

Use a Polyfill

Polyfills can retrofit legacy browsers into supporting contemporary features. W3C has an official polyfill for this particular API.

Use a Library

This whole post is about being able to achieve scroll events with native browser specs, but sometimes a well supported library is really what is needed. Our personal favorite is Waypoints. It can also offer some other features that something like IntersectionObserver doesn’t support (yet), such as easily detecting scroll direction.