Parallax Scroll Animations with the Intersection Observer API and GSAP3
How to ditch scroll-interaction libraries for GreenSock powered parallax animations.
Scroll-interaction (parallax animation) libraries are often, well, less-than magical from both development and performance standpoints. These libraries are generally packed with more features than you’ll use, aren’t easy to tree shake, and usually use the scroll event listener to do the calculations for the positioning and animation progress, which, if you aren’t careful, can negatively effect performance and cause a jittering effect on your site.
Here’s how we ditched the need for scroll-interaction libraries:
The Technologies:
GreenSock Animation Platform (GSAP) is (arguably) the most mature and performant JavaScript animation library available. GSAP recently launched it’s version 3, which added a ton of new and powerful features, in a package size about half that of 2.x. For this tutorial, a base-line knowledge of GSAP is assumed.
The Intersection Observer API is used to determine whether or not an element is in the viewport. It’s performant (no full-time on scroll event listeners). The only downside is, ≤ IE11 doesn’t support it. For those who still need to support IE11, there’s a polyfill. There are several other things that won’t work in IE11 in this tutorial, mostly related to the es6 js syntax. If you’re supporting IE11, use a syntax transformer.
Intersection Observer Setup
The Intersection Observer Constructor takes two parameters: the callback function, and options, which allow users to fine tune when, and at what level of precision the Intersection Observer fires. Note: in CodePen the rootMargin option won’t work unless you’re in debug mode.
// Target element to be observed.
const observerElement = document.querySelector('.trigger');// Intersection Observer Configuration
const observerOptions = {
root: null,
rootMargin: '0px 0px', // important: needs units on all values
threshold: 0
};// Intersection Observer Constructor.
const observer = new IntersectionObserver(
handleIntersect,
observerOptions
);// Intersection Observer Callback Function
function handleIntersect(entry) {
// If intersecting.
if (entry[0].intersectionRatio > 0) {
console.log('Element is Intersecting');
} else {
console.log('Element is NOT Intersecting');
}
};observer.observe(observerElement);
See it in action:
Fairly straight forward.
Adding the Loop
If you have a repeating scroll-triggered animation pattern across a page, setup is as simple as looping through the targets and sending those to the Intersection Observer constructor’s callback.
const observerElements = document.querySelectorAll('.trigger');const observerOptions = {
root: null,
rootMargin: '-25% 0px',
threshold: 0
};const Observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => { // animation targets
const fadeEl = entry.target.querySelectorAll('.animate-in');
if (entry.intersectionRatio > 0) { // Run GSAP animation tween.
gsap.to(fadeEl, {
duration: 1,
rotation: '-360deg',
autoAlpha: 1,
y: 0,
stagger: .2
}); // remove the observer after it's triggered
Observer.unobserve(entry.target);
}
});
},
observerOptions
);observerElements.forEach(el => {
Observer.observe(el);
});
See it in action:
To see the effect of the -25% rootMargin, CodePen needs to be opened in debug mode.
Parallax / Scroll-Tied Animations
Parallaxing complicates things just a bit. This is because we’ll need to start watching the position of an element once it’s intersecting, and also will need to control a specific tween over a specified scroll distance.
To watch the element’s position once it’s intersecting, and calculate the progress of the tween, I use GSAP’s Ticker. The Ticker is automatically throttled to the browser window’s requestAnimationFrame (rAF), which is the rate/timing that the browser paints to the screen. Like rAF, GSAP’s ticker is controlled by a callback function.
To help keep everything properly referenced, and because removing an added Ticker requires a reference to a specific callback function, we’ll move everything inside our initial forEach Loop. This also helps ensure that the ticker isn’t stopped if an element moves off screen while another is still intersecting.
The new loop will look similar to this:
const observerElements = document.querySelectorAll('.trigger');const observerOptions = {
root: null,
rootMargin: '0px 0px',
threshold: 0
};observerElements.forEach(el => {
el.observer = new IntersectionObserver(
entry => {
if (entry[0].intersectionRatio > 0) {
// starts GSAP's ticker
gsap.ticker.add(el.progressTween);
} else {
// removes GSAP's ticker
gsap.ticker.remove(el.progressTween);
}
},
observerOptions
);
// gsap ticker callback function
el.progressTween = () => {
// fires on every rAF.
// Control the GSAP timeline's progress here.
} el.observer.observe(el);});
Note: if having the ticker running the entire time the target is in view bothers you, you could very easily convert this to use an on scroll listener throttled to rAF, adding and removing the event when the element is in view, but I haven’t seen any negative impacts of just having the ticker running.
The Math (don’t worry, it’s not bad)
To tween the timeline’s progress value from 0 to 1, we’ll need to create a fraction that divides the element’s current position relative to (usually) the bottom of the viewport by the scroll distance over which we’d like the animation to take place.
To get the top value of the fraction, we’ll get the window’s height, add that to the window’s current scroll position, and subtract the trigger element’s top offset value. The bottom of the fraction can be any pixel duration, but in general will be some combination of the element and viewport’s height. For an animation that lasts the entire viewport (many parallax effects) it would be the viewport’s height plus the height of the element. So you end up with a progress equation looking like:
// Get scroll distance to bottom of viewport.
const scrollPosition = (window.scrollY + window.innerHeight);
// Get element's position relative to bottom of viewport.
const elPosition = (scrollPosition - el.offsetTop);
// Set desired duration distance.
const durationDistance = (window.innerHeight + el.offsetHeight);// Calculate tween progresss.
const currentProgress = (elPosition / durationDistance);
Putting it All Together
To add animations, we’ll define our gsap.timeline() relative to our element in the forEach loop, so that it can be referenced in the gsap.ticker callback function.
All together the basic code setup will look like:
const observerElements = document.querySelectorAll('.trigger');const observerOptions = {
root: null,
rootMargin: '0px 0px',
threshold: 0
};observerElements.forEach(el => {
const box = el.querySelector('.box'); // Set and pause GSAP timeline
const el.tl = gsap.timeline({ paused: true });
el.tl
.to(box, {x: 90, rotation: 360, ease: 'power2.inOut'})
.to(box, {x: 0, rotation: 720, ease: 'power1.inOut'});
el.observer = new IntersectionObserver(
entry => {
if (entry[0].intersectionRatio > 0) {
gsap.ticker.add(el.progressTween)
} else {
gsap.ticker.remove(el.progressTween)
}
},
observerOptions
);
el.progressTween = () => {
// Get scroll distance to bottom of viewport.
const scrollPosition = (window.scrollY + window.innerHeight);
// Get element's position relative to bottom of viewport.
const elPosition = (scrollPosition - el.offsetTop);
// Set desired duration.
const durationDistance = (window.innerHeight + el.offsetHeight); // Calculate tween progresss.
const currentProgress = (elPosition / durationDistance);
// Set progress of gsap timeline.
el.tl.progress(currentProgress);
} el.observer.observe(el);
});
Live example:
Expanding to React JS
We usually use a similar setup in our React JS builds, but use the React Intersection Observer and a custom hook to start and stop the GSAP Ticker.
If there’s interest, I’ll write another article detailing our React JS setup for this.
Other Methods
This article avoids using the scroll event listener, but there are times when it’s handy, or even necessary; for example: Triggering animations on position fixed/sticky elements, or if you need to support IE11, etc. I’m not going to go into step-by-step details on implementation, in this article, but the code should be pretty easy to dissect from this example: