Note23 minute read

Intersection Observer: Lazy Loading, Animations, and Infinite Scroll Without Libraries

How to use Intersection Observer for lazy loading, animations, and infinite scroll with native APIs.

  • frontend
  • javascript
  • performance

Hey devs!

Series on Web APIs: what you can do without installing a library, and what changes once you know what the browser already ships.

As a front-end dev, I've needed infinite scroll, animation when an element enters the viewport, and image lazy-loading without reaching for a package. The old path was getBoundingClientRect in a scroll loop: it works until the page stutters.


Concepts and usage

The Intersection Observer API gives you a way to watch intersection changes asynchronously. With it, your site no longer has to handle that on the main thread, and the browser can manage observations however it wants.

You declare a callback function that runs when:

  • A target element crosses (fully or partially, depending on config) the root element.

  • The Observer is first asked to watch a target element.

The API has full support in all modern browsers, with caveats for Safari (desktop and iOS) and Firefox for Android, where the root element cannot be a document.


Creating an Intersection Observer

To create an intersection observer, call its constructor with a callback as the first argument and an options object as the optional second:

let options = {
  root: document.querySelector('#rootElement'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

Intersection observer options

The options object passed to the IntersectionObserver() constructor controls when the callback runs:

  • root: A specified ancestor element or the viewport when no element is declared or the value is null.

  • rootMargin: Sets margin bounds on the root element, expanding or shrinking it before computing an intersection. Values work like CSS, e.g. "10px 20px 30px 40px" (top, right, bottom, left).

  • threshold: The intersection ratio, the percentage of the target visible relative to the root: a value between 0.0 and 1.0. The callback runs whenever visibility crosses the declared value, up or down. It can be:

    • A number. E.g. 0.5. Callback runs when visibility crosses 50%.
    • An array of numbers. E.g. [0, 0.25, 0.5, 0.75, 1]. The callback runs at each declared percentage. In this case, every 25% of visibility.

Declaring an element to observe

Once you've created the observer, declare an element for it to watch:

let target = document.querySelector('#targetElement');
observer.observe(target);

At that moment, the callback runs the first time, even if the target isn't visible yet.

Whenever the target's visibility crosses a threshold value, the callback is invoked with a list of IntersectionObserverEntry objects and the observer itself.

Keep in mind the callback itself still runs on the main thread. Don't pile heavy logic in there:

let callback = (entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      /* Verificamos o estado da 'entry' e efetuamos
      as alterações necessárias caso ela esteja visível */  
    }
  });
};

Most use cases only need the isIntersecting property on the entry: a boolean for whether the target is crossing the root, given your options.

For more properties on IntersectionObserverEntry, see the MDN docs.

With that base in place, let's get to use cases.


Files used

You can use the repository for this article, with final files split into folders per case.


Lazy-loading

Imagine loading every asset on a full page and the user never sees them because they navigated away. Wasted resources for them (on mobile, data spent for nothing) and for you (files served that never got used).

From there, let's build a page where images load only when visible.

Starting with index.html:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="style.css">
  <title>Lazy Loading</title>
</head>

<body>
  <section>
    <img class="lazy" src="placeholder.png" data-src="https://picsum.photos/300/200" />
  </section>
  <section>
    <img class="lazy" src="placeholder.png" data-src="https://picsum.photos/300/201" />
  </section>
  <section>
    <img class="lazy" src="placeholder.png" data-src="https://picsum.photos/300/202" />
  </section>
  <section>
    <img class="lazy" src="placeholder.png" data-src="https://picsum.photos/300/203" />
  </section>
  <script src="script.js"></script>
</body>

</html>

On the img tags, we set a placeholder in src for the initial render. In data-src, the real image URL. We also add the lazy class to select the images.

Season with style.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  font-family: "Roboto", sans-serif;
  background-color: #f5f5f5;
}

section {
  height: 100%;
  width: 100%;
  align-items: center;
  display: flex;
  justify-content: center;
}

Now we watch the images and, when visible, swap the placeholder for the real URL. In script.js:

Select the images first.

const images = document.querySelectorAll('.lazy');

Create the Observer.

const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const image = entry.target;
      image.src = image.dataset.src;
      image.classList.remove('lazy');
      observer.unobserve(image);
    }
  });
});

Inside the callback, we forEach over entries. For each entry, we check if it crosses the visible area (entry.isIntersecting). If yes, we set entry.target as image, replace src with data-src, remove the lazy class, and tell the observer to stop watching that image.

Then we forEach over the NodeList from our selector and observe each image:

images.forEach(image => {
  observer.observe(image);
});

Images already viewed have the final URL in src. The rest keep the placeholder:

Screenshot showing two images in the DOM, one with the final URL and one with the placeholder

Open the Network tab in DevTools and you'll see images load as they appear on screen.

You can check the result at this link.


Scroll animations

Good for more interactivity on the page. When an element becomes visible, we add a CSS class for the effect. We can remove it when the element leaves the viewport, so the effect can repeat on the next scroll.

Start with index.html:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="style.css">
  <title>Lazy Loading</title>
</head>

<body>
  <section>
    <p class="animate">
      When this block crosses the viewport, the animation class kicks in. When it leaves, it drops off, and the effect can repeat on the next scroll.
    </p>
  </section>
  <section>
    <p class="animate">
      When this block crosses the viewport, the animation class kicks in. When it leaves, it drops off, and the effect can repeat on the next scroll.
    </p>
  </section>
  <section>
    <p class="animate">
      When this block crosses the viewport, the animation class kicks in. When it leaves, it drops off, and the effect can repeat on the next scroll.
    </p>
  </section>
  <section>
    <p class="animate">
      When this block crosses the viewport, the animation class kicks in. When it leaves, it drops off, and the effect can repeat on the next scroll.
    </p>
  </section>
  <script src="script.js"></script>
</body>

</html>

The p tags are picked up by the observer through the animate class.

Add style.css, including animate and animate--active. The second one drives the effect.

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  font-family: "Roboto", sans-serif;
  background-color: #f5f5f5;
}

section {
  height: 100%;
  width: 100%;
  padding: 20px;
  align-items: center;
  display: flex;
  justify-content: center;
}

.animate {
  width: 300px;
  opacity: 0;
  transform: translateX(-100px);
  transition: all 0.5s ease-in-out;
}

.animate--active {
  opacity: 1;
  transform: translateX(0);
  transition: all 0.5s ease-in-out;
}

In script.js, select the text blocks via the animate class.

const animatedTexts = document.querySelectorAll('.animate');

Create the observer. For each entry, check if it's crossing the screen. If yes, add animate--active. Otherwise, remove it.

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('animate--active');
    } else {
      entry.target.classList.remove('animate--active');
    }
  });
});

Finally, forEach over the text list and add each one to the observer.

animatedTexts.forEach(text => {
  observer.observe(text);
});

The text slides in from the left to the center of the flex-container.

See the result at this link.

From here you can do whatever you want with any element: add or remove classes, or use CSS animations, until you get the effect you want.


Infinite scroll

Here we build a page with infinite scroll. Whenever we reach the last list item, more items get appended, indefinitely.

Useful for product lists, for example: the user scrolls and keeps seeing items without pagination or extra navigation.

In index.html we create a container div where items will be added. Below it, a p with loading... marks the end of the list and signals there's more to see.

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="style.css">
  <title>Document</title>
</head>

<body>
  <main>
    <div class="container"></div>
    <p>loading...</p>
  </main>
  <script src="script.js"></script>
</body>

</html>

In style.css, styles for the images we'll load.

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  font-family: "Roboto", sans-serif;
  background-color: #f5f5f5;
}

.container {
  height: 100%;
  width: 100%;
  margin: 40px 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 40px;
}

img {
  width: 320px;
  height: 320px;
  object-fit: cover;
}

In script.js, select the container:

const container = document.querySelector('.container');

We'll create a getTenRandomImages function that returns 10 images with random URLs. It populates the container. In production, swap it for an API call that returns your data.

const getTenRandomImages = () => {
  const images = [];
  for (let i = 0; i < 10; i++) {
    const image = document.createElement('img');
    image.src = `https://picsum.photos/300/300?random=${Math.random()}`;
    images.push(image);
  }
  return images;
};

Create the observer. In the callback, if the watched entry (the container's last child) crosses the visible area, getTenRandomImages adds 10 more images to the container, the entry stops being observed, and the new last child (lastElementChild) of the container gets observed.

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      container.append(...getTenRandomImages());
      observer.unobserve(entry.target);
      observer.observe(container.lastElementChild);
    }
  });
});

Finally, append the first 10 images to the container and observe its last child so new images load only when that element is visible.

container.append(...getTenRandomImages());
observer.observe(container.lastElementChild);

See the result here.


Wrapping up

Lazy-load, scroll animation, and infinite lists all use the same mechanism: watch intersection and react in the callback. The API removes scroll polling from the main thread; the callback still runs there. Keep the logic light.

In React or Vue, swap querySelector for the framework ref. The observer stays the same.


References:

Intersection Observer API - Web APIs | MDN Intersection Observer | W3C