How to Make a Scroll-Triggered Animation With Basic JavaScript

Avatar of Md Shuvo
Md Shuvo on (Updated on )

Let’s put those CSS skills to work! Claim $50 in free hosting credit on Cloudways with code CSSTRICKS.

A little bit of animation on a site can add some flair, impress users, and get their attention. You could have them run, no matter where they are on the page, immediately when the page loads. But what if your website is fairly long so it took some time for the user to scroll down to that element? They might miss it.

You could have them run all the time, but perhaps the animation is best designed so that you for sure see the beginning of it. The trick is to start the animation when the user scrolls down to that element — scroll-triggered animation, if you will.

To tackle this we use scroll triggers. When the user scrolls down to any particular element, we can use that event to do something. It could be anything, even the beginning of an animation. It could even be scroll-triggered lazy loading on images or lazy loading a whole comments section. In that way, we won’t force users to download elements that aren’t in the viewport on initial page load. Many users may never scroll down at all, so we really save them (and us) bandwidth and load time.

Scroll triggers are very useful. There are many libraries out there that you can use to implement them, like Greensock’s popular ScrollTrigger plugin. But you don’t have to use a third-party library, particularly for fairly simple ideas. In fact, you can implement it yourself using only a small handful of vanilla JavaScript. That is what we are going to do in this article.

Here’s how we’ll make our scroll-triggered event

  • Create a function called scrollTrigger we can apply to certain elements
  • Apply an .active class on an element when it enters the viewport
  • Animate that .active class with CSS

There are times where adding a .active class is not enough. For example, we might want to execute a custom function instead. That means we should be able to pass a custom function that executes when the element is visible. Like this:

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

We’ll also attempt to handle scroll triggers for older non-supporting browsers.

But first, the IntersectionObserver API

The main JavaScript feature we’re going to use is the Intersection Observer. This API provides a way to asynchronously observe changes in the intersection of a target element — and it does so more in a more performant way than watching for scroll events. We will use IntersectionObserver to monitor when scrolling reaches the point where certain elements are visible on the page.

Let’s start building the scroll trigger

We want to create a function called scrollTrigger and this function should take 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')

Now let’s create the addObserver function that want to attach to the element using IntersectionObserver:

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')

If we do this and scroll to an element with a .scroll-reveal class, an .active class is added to that element. But notice that the active class is added as soon as any small part of the element is visible.

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

But that might be overkill. Instead, we might want the .active class to be added once a bigger part of the element is visible. Well, thankfully, IntersectionObserver accepts some options for that as its second argument. Let’s apply those to our scrollTrigger function:

// 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'
})

And just like that, our first two agenda items are fulfilled!

Let’s move on to the third item — adding the ability to execute a callback function when we scroll to a targeted element. Specifically, let’s pass the callback function in our 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.

Great! There’s one last thing that we need to take care of: legacy browser support. Certain browsers might lack support for IntersectionObserver, so let’s handle that case in our addObserver function:

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)
  }
})

Here’s that live demo again:

And that’s all for this little journey! I hope you enjoyed it and learned something new in the process.