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
Table of Contents
- 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.
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) }
})
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.