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
scrollTriggerfunction that can be applied to selected elements. - Add an
.activeclass to an element once it enters the viewport. - Drive the animation from that
.activeclass 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:
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)
}
})

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.



