Let’s explore the intersection between animation and scrolling with Vue.js and the native Intersection Observer API. This is the second blog post of a three-part series.
4minutes remaining
Using scroll as a trigger
In part one of this series, we presented the power of motion on websites and how animations are triggered by the users themselves: a click, a tap, a swipe, or a hover.
In this blog post, we will discuss another way to trigger movement on your site: scroll. You can change classes, update styles, add or remove DOM elements, or even run an animation function for even more intricate effects and timing – more on that in our third and final post of this series.
While well-executed scroll animations may look effortless, the implementation of a scroll-triggered animation can be much more complex compared to traditional animations. A developer, through close collaboration with their UX and/or Creative counterpart, needs to decide:
Which element to watch to trigger the animation
How much of the element should be “in view” before the animation can begin
What constitutes “in view” for an element, especially on smaller screens like tablets and phones
If the animation should execute differently based on scroll direction (scrolling down versus scrolling up)
If the animation should animate every time the element is in view or just the first time
What the duration of the animation should be so that the user does not miss contextually important parts of the animation
If the scroll speed impacts the speed or duration of the animation
Ultimately there are no right answers. Instead, take the time to consider the role of your scroll-based animation and its importance in the story you are sharing with your site visitors.
Meet the Intersection Observer API
Until recently, web developers would need to turn to event listeners and “listen” for a scroll event. Each time a scroll event is fired, it triggers a check to see if a given element is within the threshold of the viewport. If it is, it would trigger another function: adding or removing an element from the DOM, changing a style or class, playing an animation function, etc.
This check ends up firing hundreds or even thousands of times as a user scrolls down the page because each scroll triggers the watcher. This can get very expensive in terms of resources. If you have multiple elements being watched on a single page, your site could take a performance hit and negatively affect conversion rates.
The Intersection Observer API lets code register a callback function that is executed whenever an element they wish to monitor enters or exits another element (or the viewport), or when the amount by which the two intersect changes by a requested amount.
With Intersection Observer API, we no longer need to take up the main thread to watch the entire document with costly event listeners. Instead, we are watching a single element, and the browser can optimize this activity as it sees fit. Plus, the Intersection Observer API is built into all modern browsers, so no need to pull in any external libraries.
With great Animation power, comes great Animation responsibility: motion used too often or aggressively or without a clear purpose can make the site confusing, hard to navigate or interact with, or overwhelming.
Tim & Megan
How does it work?
Simply speaking, each time you wish to observe an element, we create a new instance of an intersection observer:
const observer = new IntersectionObserver(([entry]) => {
if (entry && entry.isIntersecting) {
console.log(“Look, Ma! I’m intersecting!”)
}
}, options);
As you can see, the IntersectionObserver takes a callback function (written out in arrow notation above) and then options, which must be in the form of an IntersectionObserver options object.
Root: The root element represents the parent bounding box of the element we are watching for intersection. If set to null (the default), it will use the entire viewport.
Root margin: The root margin is the offset (in string form) of the root element. It defaults to "0px 0px 0px 0px". This is helpful if you have a sticky footer you wish to account for when timing an animation on scroll.
Threshold: The threshold takes either a single value between 0 and 1 or an array of such values, and it represents how much of the element should be in view before the callback should be executed. For instance, threshold: 0.75 means that the callback will execute once the element is 75% visible. If you wanted the callback to execute each time a new 25% of the element is visible, you would use the array notation: threshold: [0, 0.25, 0.5, 0.75, 1]. Using threshold: 0 means that as soon as a single pixel passes the threshold, the callback will execute.
Once you have your observer, you can use it to “observe” any number of elements through the observe method:
const observer = new IntersectionObserver
const elementToWatch = document.querySelector(“#someElement”)
observer.observe(elementToWatch)
Depending on the options you passed into your IntersectionObserver instance, the callback will fire on elementToWatch once the element has passed into the viewport. This is particularly handy for lazy-loading images, infinite scrolling applications, and, most relevant for us, timing animations.
Cleaning up afterward
Much like with event listeners that should be removed once they are no longer being used, Intersection Observer instances should also be handled to keep your site performant.
To stop observing a single element:
const observer = new IntersectionObserver
observer.unobserve(elementToWatch)
To remove all elements from being observer with a particular IntersectionObserver instance:
const observer = new IntersectionObserver
observer.disconnect()
Bringing in Vue 3
Using a composable
New to Vue 3, a composable is a piece of reusable code that can be imported and used directly within any other piece of Vue code (normally a Vue component, but theoretically even other composables). Those familiar with Vue 2 will find them similar to both directives and mixins, both of which are no longer used within Vue’s Composition API. The benefit of composables versus mixins and directives is that they allow for more flexible use throughout your application and ultimately cleaner, DRYer code.
Because of the nature of the Intersection Observation API, it makes a perfect candidate for a composable. You only need to write your implementation once, and then you can reuse it throughout your codebase with different options, targets, and callback effects.
As you can see in the CodeSandbox embedded above, we have abstracted out the functionality we’ll need in an onIntersect composable. This composable function takes the following arguments:
a target, which is the element we want to observe
a callback upon intersection,
an optional callback when exiting the intersection,
an options object, and
a toggle of whether the callback(s) should execute only once or if they should be redone each time a user scrolls back and forth past the target element.
To allow for further functionality within the component itself (disconnecting, reconnecting, etc.), the composable returns the observer object. Here's the whole composable all together:
/**
* @function onIntersect
* @param {HTMLElement} elementToWatch elementToWatch
* @param {function} callback callback once element is intersecting
* @param {Boolen} once if callback only run one time
* @param {Object} options Intersection Observer API options
* @return {type} observer
*/
export const onIntersect = (
elementToWatch,
callback,
outCallback = () => {},
once = true,
options = { threshold: 1.0 }
) => {
// Initiate the observer
const observer = new IntersectionObserver(([entry]) => {
// If the element to watch is intersecting within the threshold
if (entry && entry.isIntersecting) {
// Run the callback
callback(entry.target);
// If the callback should only run once, unobserve the element
if (once) {
observer.unobserve(entry.target);
}
}
// If the element is not intersecting, run the (optional) unintersecting callback
else {
outCallback(entry.target);
}
}, options);
// Observe the element
observer.observe(elementToWatch);
// Returns the observer so it can be further used in the component
return observer;
};
Even with the addition of composable functions, components remain the backbone of your application within Vue 3. As we mentioned in our first post of this series, Vue components allow you to abstract away repeated pieces of logic and keep your codebase clean and DRY.
Where it all intersects
The Intersection Observer API is an amazing tool in our arsenal. Combined with the power of Vue, we can easily and quickly create reusable components to add motion to our site. It’s important to remember that with great Animation power, comes great Animation responsibility: motion used too often or aggressively or without a clear purpose can make the site confusing, hard to navigate or interact with, or overwhelming. Consider who your site’s primary audience is, what kind of devices they typically view the site on, and what the primary purpose of the site is.
In part three of this series, we will discuss how to take these tools and pair them with some of the great animation libraries like GSAP, Anime.js, and Motion One to create even more complex and sequenced animations. This can lead to some great effects that appear effortless and elegant to your user.
Have a motion project you need help with? Imarc is here and ready to help. Let's talk!