Introduction

This article is the first part of the series about Lazy Loading.

In this article, we'll take a look at the Intersection Observer API and see how we can use it in real-world apps. We'll start by talking about what an Intersection Observer API is and then walk through an example that shows how to use it.

We will also take a look at using the Intersection Observer API for lazy loading images in Server-side rendering (SSR) or Client-side rendering (CSR) applications.

I hope this article will inspire you to implement some of the features that Intersection observer offers in your apps. It's definitely worth taking the time to learn about it because the Intersection Observer API has been around in JavaScript for a while, but not many developers know about it. It's a powerful feature that can be used to improve the performance of your JavaScript apps.

If you are still learning JavaScript and you are eager to learn more, I recommend services like Codecademy where you can easily learn programming languages and frameworks. There is also a Basic plan where you don't need to spend any money.

Codecademy - Learn JavaScript for free

What is an intersection observer?

An Intersection Observer API allows you to react to the intersection between the viewport of the browser and an HTML element.

The viewport is the user's visible area of a web page. It's the area that you can see on the screen without scrolling or zooming.

For example, you can detect when the user starts interacting with an element on the page, respond to that interaction, and update the DOM to reflect the change.

This is how you define an IntersectionObserver object:

const observer = new IntersectionObserver(callback, options)

observer.observe(targetElement)

You simply create an instance of the Intersection Observer class and then pass the target element to it using the observe method. What we interested here is the callback option passed into the constructor above. You might think that it will be executed when the user intersects the element with the viewport, but no, that’s not entirely true.

The callback function is executed each time the browser notices a change in the viewport boundaries relative to your target element. If it sounds confusing, don't worry, I'm going to show you how you can detect the intersection between the viewport and an HTML element.

React to intersection

Let's make an example of lazy loading an image.

Lazy loading means not loading images when the page is first loaded, but only when it's needed. It helps to improve page loading speed by delaying the load of big images until they're needed by the user.

In my project I have an image file called image.jpg, so I'm going to create an image element with the id of “target” like this:

<img src="image.png" id="target" style="width: 100%" />

If I preview that in the browser, I get the expected result:

undefined

For our experiment, this image has to be below the viewport of our browser view. So, I will add a bunch of lorem ipsum text above the image just to hide it. I will write lorem2000 in VS Code and hit a tab key. This is what I see in a browser now:

Web page with lots of text

The image is still below the text and if I scroll down, I'll see it.

I'm going to open my console, go to “Network” tab, select “images” to see only loaded images, and refresh the page.

Web page with lots of text and dev tools opened

As you can see, in my screenshot here, we have image.png being loaded, even though we didn't see it. Let's change it so that it will appear only when we actually need it.

I'm going to change the src attribute of the image to data-src to prevent it from being loaded by default in the HTML markup.

<img data-src="image.png" id="target" style="width: 100%">
<script src="index.js"></script>

And when the user scrolls into the element, we will change data-src to src and the image will be loaded by the browser.

In my index.js file, I will create an instance of IntersectionObserver, and call the observe method on the image element like this:

const target = document.getElementById('target')

const observer = new IntersectionObserver()
observer.observe(target)

The instance of Intersection Observer requires the callback function as the first parameter. It's precisely what we need for handling the loading of our image. This is what the callback function would look like:

function observerHandler(entries, observer) {
    //
}

It has 2 parameters, the first one is an array of DOM elements which report a change in its intersection status, the second one is a helper object that contains the properties of the Observer object itself. For now, we only care about the first parameter — entries.

If one of our entries has an intersection ratio greater than zero, this means that the element entered our visible area. This is what the Observer handler looks like:

function observerHandler(entries, observer) {
    for (const entry of entries) {
        if (entry.intersectionRatio > 0) {
            target.src = target.dataset.src
        }
    }
}

If I refresh the page in the browser and scroll down, we will see the image appearing when we scroll to it for the first time.

This is incredible how little code it takes to load an image on scroll, once the element is inside the viewport. Now, if you think about the power of the Intersection observer, you can think of so many ways you can use it.

If you want to add lazy loading to your apps, you can try to implement it by yourself or use some existing libraries for that. One of those libraries is called “Smooth loader”. I've created this package in April 2021, and you can add it to your projects easily. You can find it here on GitHub. It has a great documentation, and I've used it in many projects.

Usage with frameworks

I've recently built a Server-side rendering app with Vue on the frontend. As I was optimizing the home page to make it as fast and responsive as possible, I thought, “What if I make AJAX requests to the server only when I need to do them instead of loading everything at once?”.

Because home pages often consist of several sections and components. My initial thought was to combine numerous AJAX requests into a single one, but as it turns out, this would still make an impact on page loading speed.

So, I've written code that would detect if the element was inside a viewport and only render it when it's visible on the screen. This way, I could reduce the number of HTTP requests and load the page faster!

This solution will work for any kind of apps that have a dynamic loading process or that pull in data from external sources like an API.

I abstracted this logic into a reusable component so that you can wrap any Vue component with it and make it render only when it is on the screen. I've called the component LazyLoading.vue, this is the code:

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AppearTransition from '@/components/Transition/AppearTransition.vue'

const rootElement = ref<Element | null>(null)
const renderComponent = ref(false)

onMounted(() => {
    if (rootElement.value) {
        createIntersectionObserver().observe(rootElement.value)
    }
})

function createIntersectionObserver(): IntersectionObserver {
    return new IntersectionObserver((entries, observer) => {
        for (const entry of entries) {
            if (entry.isIntersecting) {
                renderComponent.value = true
            }
        }
    })
}
</script>

<template>
    <div ref="rootElement">
        <appear-transition>
            <slot v-if="renderComponent" />
        </appear-transition>
    </div>
</template>

I've also added an appear-transition component to my page. The appear-transition is just a regular Vue transition component with some Tailwindcss classes:

<template>
    <transition
        enter-active-class="transition-opacity duration-500"
        enter-from-class="opacity-0"
        enter-to-class="opacity-100"
        leave-active-class="transition-opacity duration-200"
        leave-from-class="opacity-100"
        leave-to-class="opacity-0"
        appear
    >
        <slot />
    </transition>
</template>

I'm a big believer in transitions, and I love when you visit some page and everything there is smooth as you scroll and interact with everything.

You can easily rewrite this Vue component into React or Angular without a problem, since it's all JavaScript at the end of the day.

Usage with animations

The other cool thing about this is that you can also use it to add animations to your page. For example, you could change the styles of certain element to make when the user scrolls into it or scrolls out of it.

For example, if our element is on the top of the page, and we want to detect when it gets off the screen, then we could write a handler like this:

function observerHandler(entries, observer) {
    for (const entry of entries) {
        if (entry.intersectionRatio < 1) {
            console.log('lost')
        }
    }
}

Instead of checking the ration being greater than zero, we now check if it's actually less than zero. As soon as we lose our element on the screen, we will log that we're “lost.” With this code in place, we can then change the styles to make it as we are fading out.

But, this is a bit tricky because we will not be able to see the animation because it will be triggered as soon as we lose the element. To fix this, we need to add a threshold of at least 0.3 like this:

const observer = new IntersectionObserver(observerHandler, {
    threshold: 0.3,
})
observer.observe(target)

This is means that when 30% of the target is visible within the root element, the observer will execute the handler. The maximum value for threshold is 1.0, which means that the entire target must be visible before firing the handler.

By default, the root element is the viewport of the user's browser, but you can change it to any element that you need. You just need to specify this in the observer configurations like this: root: document.getElementById('my-element').

Observer options

The Intersection Observer constructor accepts options as the second parameter. Some options are the following:

threshold - as we have seen above, this is the ratio with which the elements must be overlapping before the handler is executed. The default is 0, it means that as soon as the first pixel is visible, the callback will be executed. The 1.0 value is the maximum.

root - By default, the root element is the viewport of the user's browser, but you can change it to any element that you need. You just need to specify this in the observer configurations like this: root: document.getElementById('my-element'). It must be the ancestor of the target element.

rootMargin - as the name suggests, this value is used to set the margin between the target and the root element. It defaults to null, which means no margin will be applied to the root element. If you set a fixed value like “5px” or “10px”, the margin will be applied only when both the target and the root elements are on the viewport at the same time. Otherwise, it will not have any effect.

You can also read about these parameters on the MDN page, but I usually use only a threshold parameter if I need it.

Conclusion

As we saw in this article, the Intersection Observer API is very straightforward and easy to use. It can be practical when working with animations and web page optimizations.

Something as delaying the AJAX request until the user actually needs the data. It's simple to implement, but compelling when it comes to increasing the user experience of your site.

If you would rather not write your library for lazy loading images or loading components, you can find several good libraries on NPM which can do those things for you.

In the next article, we'll go into frameworks and build a Vue component wrapper for encapsulating the Lazy Loading logic. If you use Svelte as a framework of your choice, you can read a “Lazy loading: Boost your Svelte app's performance” article of this series about Lazy Loading.

For any clarifications or suggestions, please, leave a comment down below and I will answer as soon as I can.

Keywords: lazy, loading, js, frontend, smooth