Why trigger animations on scroll

Animation can draw attention and add polish, but timing matters. An animation that runs on page load may finish long before the user reaches the element it belongs to, and letting it loop indefinitely removes any guarantee that the beginning was seen. Starting the animation when the element scrolls into view solves both problems.

Scroll triggers are not limited to animation. The same signal can drive lazy loading of images or of an entire comments section, so resources outside the initial viewport are never fetched for users who never scroll that far — saving bandwidth and load time for them and for the server. Libraries such as Greensock's ScrollTrigger plugin exist for this, but simple cases can be handled with a small amount of vanilla JavaScript and the IntersectionObserver API.

The approach

Three steps cover the common case:

  • Write a scrollTrigger function that can be applied to selected elements.
  • Add an .active class to an element once it enters the viewport.
  • Drive the animation from that .active class in CSS.

Sometimes a class is not enough and a custom function needs to run instead, which means the trigger must accept a callback that fires when the element becomes visible:

scrollTrigger('.loader', {
  cb: function(el) {
    el.innerText = 'Loading ...'
    loadContent()
  }
})

Legacy browsers that lack IntersectionObserver also need a fallback path.

Using IntersectionObserver

The central API here is the Intersection Observer, which asynchronously observes changes in the intersection of a target element and does so more performantly than listening for scroll events. IntersectionObserver is what watches for the moment scrolling brings the chosen elements into view.

Building the trigger

The scrollTrigger function takes a selector as its argument:

function scrollTrigger(selector) {
  // Multiple element can have same class/selector,
  // so we are using querySelectorAll
  let els = document.querySelectorAll(selector)
  // The above `querySelectorAll` returns a nodeList,
  // so we are converting it to an array
  els = Array.from(els)
  // Now we are iterating over the elements array
  els.forEach(el => {
    // `addObserver function` will attach the IntersectionObserver to the element
    // We will create this function next
    addObserver(el)
  })
}
// Example usage
scrollTrigger('.scroll-reveal')

The element is then attached to an observer through an addObserver function:

function scrollTrigger(selector){
  let els = document.querySelectorAll(selector)
  els = Array.from(els)
  els.forEach(el => {
    addObserver(el)
  })
}
function addObserver(el){
    // We are creating a new IntersectionObserver instance
    let observer = new IntersectionObserver((entries, observer) => { // This takes a callback function that receives two arguments: the elements list and the observer instance.
      entries.forEach(entry => {
        // `entry.isIntersecting` will be true if the element is visible
      if(entry.isIntersecting) {
        entry.target.classList.add('active')
        // We are removing the observer from the element after adding the active class
        observer.unobserve(entry.target)
      }
    })
  })
  // Adding the observer to the element
  observer.observe(el)
}
// Example usage
scrollTrigger('.scroll-reveal')

At this point, scrolling to an element with a .scroll-reveal class adds an .active class to it — but the class lands as soon as any small part of the element becomes visible:

Wcroll-triggered animation with the code for it to the left and DevTools open on the right.

That threshold is often too eager. IntersectionObserver accepts an options object as its second argument, which can require a larger portion of the element to be visible before .active is applied:

// Receiving options as an object
// If the user doesn't pass any options, the default will be `{}`
function scrollTrigger(selector, options = {}) {
  let els = document.querySelectorAll(selector)
  els = Array.from(els)
  els.forEach(el => {
    // Passing the options object to the addObserver function
    addObserver(el, options)
  })
}
// Receiving options passed from the scrollTrigger function
function addObserver(el, options) {
  let observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
      if(entry.isIntersecting) {
        entry.target.classList.add('active')
        observer.unobserve(entry.target)
      }
    })
  }, options) // Passing the options object to the observer
  observer.observe(el)
}
// Example usage 1:
// scrollTrigger('.scroll-reveal')
// Example usage 2:
scrollTrigger('.scroll-reveal', {
  rootMargin: '-200px'
})

Callback support

The callback case is handled by passing the function into the options object as cb:

function scrollTrigger(selector, options = {}) {
  let els = document.querySelectorAll(selector)
  els = Array.from(els)
  els.forEach(el => {
    addObserver(el, options)
  })
}
function addObserver(el, options){
  let observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
      if(entry.isIntersecting){
        if(options.cb) {
          // If we've passed a callback function, we'll call it
          options.cb(el)
        } else{
          // If we haven't, we'll just add the active class
          entry.target.classList.add('active')
        }
        observer.unobserve(entry.target)
      }
    })
  }, options)
  observer.observe(el)
}
// Example usage:
scrollTrigger('.loader', {
  rootMargin: '-200px',
  cb: function(el){
    el.innerText = 'Loading...'
    // Done loading
    setTimeout(() => {
      el.innerText = 'Task Complete!'
    }, 1000)
  }
})
An updated animated screenshot of the same scroll-triggered animation. As boxes enter the screen from the bottom, a they rotate. A "loading" message that changes to "finished loading" message is the last element to scroll into view. The code is open to the left of the animation.

Fallback for older browsers

Some browsers do not support IntersectionObserver. The addObserver function can detect that case and branch accordingly:

function scrollTrigger(selector, options = {}) {
  let els = document.querySelectorAll(selector)
  els = Array.from(els)
  els.forEach(el => {
    addObserver(el, options)
  })
}
function addObserver(el, options) {
  // Check if `IntersectionObserver` is supported
  if(!('IntersectionObserver' in window)) {
    // Simple fallback
    // The animation/callback will be called immediately so
    // the scroll animation doesn't happen on unsupported browsers
    if(options.cb){
      options.cb(el)
    } else{
      entry.target.classList.add('active')
    }
    // We don't need to execute the rest of the code
    return
  }
  let observer = new IntersectionObserver((entries, observer) =>; {
    entries.forEach(entry => {
      if(entry.isIntersecting) {
        if(options.cb) {
          options.cb(el)
        } else{
          entry.target.classList.add('active')
        }
        observer.unobserve(entry.target)
      }
    })
  }, options)
  observer.observe(el)
}
// Example usages:
scrollTrigger('.intro-text')
scrollTrigger('.scroll-reveal', {
  rootMargin: '-200px',
})
scrollTrigger('.loader', {
  rootMargin: '-200px',
  cb: function(el){
    el.innerText = 'Loading...'
    setTimeout(() => {
      el.innerText = 'Task Complete!'
    }, 1000)
  }
})

A live demo accompanies this walkthrough.