What is Intersection Observer API?

SaviSavi
6 min read

The Intersection Observer API lets you check when an element on a webpage comes into or goes out of view, based on its position relative to the viewport or a parent element. It does this asynchronously without blocking the main thread, so it’s more efficient and saves resources.

In the past, checking if elements intersected (like when scrolling) involved running code that could slow down the main thread and make websites less responsive. This was especially problematic on pages with lots of dynamic content, like infinite scrolling sites with ads and animations.

The Intersection Observer API allows us to register a callback function which will only get's triggered when element is visible in the viewport or intersecting other element. This approach moves the intersection checking off the main thread, letting the browser handle it more efficiently and improving performance.

The one thing which Intersection Observer can't do how many exact pixels are overlapping. it can only measure how much percentage of the element is visible in the viewport.

Why use Intersection Observer API?

  1. Lazy Loading of Images :- Suppose we have a webpage with many images or assets. Downloading all images and assets at once can lead to longer initial page load time and higher network bandwidth consumption. To address this, we use lazy loading, which means that only the images or assets visible in the viewport are downloaded on demand. This approach reduces the initial load time and saves bandwidth since images that are not yet visible will not be loaded until the user scrolls down.

  2. Infinite Scrolling :- To add infinite scrolling to a web page similar to YouTube's homepage, where resources are automatically loaded as you reach the end of the current content instead of clicking a button, you can use the Intersection Observer API. This approach allows for efficient and smooth loading of new content as the user scrolls.

  3. Reporting of visibility of advertisements:- To determine how many times and for how long an advertisement is visible on a webpage, ensuring accurate billing for advertisers, we use the Intersection Observer API.

  4. Animations & Other Heavy tasks :- To trigger Animations and other heavy tasks only they are visible to user.

The Intersection Observer API allows us to register a callback which triggers when the target element is intersecting specified element or viewport.

const options = {
root : rootElement, // if specified element is viewport then null else specied element
rootMargin : 10px 20px 10px 20px,
threshold : 1.0 // from 0.0 to 1.0
};

const observer = new IntersectionObserver(callback, options);

The options object help use to configure when and how the callback function should be called. It has 3 properties.

Root :- Typically, we want to watch for intersection changes with regard to the target element's closest scrollable ancestor, or, if the target element isn't a descendant of a scrollable element, the device's viewport. To watch for intersection relative to the device's viewport, specify null for the root option.

Margin:- margin around the root element. It has same values as CSS margin property. It adds the margins to the boundary around root element before computing the intersection.

Threshold:- It is the Intersecting Ratio between the target element and it's root element. It's values is in percentage starts from 0.0 to 1.0. The threshold value can be a number (0.0 to 1.0) or array of numbers (0.0 to 1.0). the value 0.0 means if even 1 pixel of the target element is visible then trigger the callback. the value 1.0 means do not trigger the callback until each and every pixels of the target element is visible. The values can be array of numbers as [0.0, 0.25, 0.50, 0.75, 1.0] means trigger the callback every time the visibility of target element passes 25%.

Let us first build the Lazy Loading functionality and then move to infinite scrolling.
We will use React for this but with basic understanding, it can be replicated in other JS frameworks & libraries.

We have a simple React App created using Vite. Below files are added

PokemonCard.tsx :- Reusable component for Lazy Loading of images using Intersection Observer API

// pokemonCard.tsx
import { useState, useRef, useMemo, useEffect } from 'react';
import styles from "./pokemon.module.css"

interface PokemonCardTypes {
  image: string;
  name: string;
}

const PokemonCard = ({ image, name }: PokemonCardTypes) => {
  const targetElement = useRef<HTMLDivElement>(null);
  const [imageUrl, setImageUrl] = useState<string | null>(null);
  const [isImageLoaded, setIsImageLoaded] = useState(false);

  const options = useMemo(() => ({
    root: null,
    rootMargin: '10px',
    threshold: 0.0,
  }), []);

  const handleObserver = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
    if (entries[0].isIntersecting) {
      setImageUrl(image);
      observer.unobserve(entries[0].target); // Stop observing after loading
    }
  };

  useEffect(() => {
    const observer = new IntersectionObserver(handleObserver, options);

    if (targetElement.current) {
      observer.observe(targetElement.current);
    }

    return () => {
      if (targetElement.current) {
        observer.unobserve(targetElement.current);
      }
    };
  }, [targetElement, options, image]);

  return (
    <div ref={targetElement} className={styles.card}>
      <div className={styles.name}>{name}</div>
      {imageUrl ? (
        <img
          src={imageUrl}
          alt={name}
          className={styles.image}
          style={{ opacity: isImageLoaded ? 1 : 0 }}
          onLoad={() => setIsImageLoaded(true)}
        />
      ) : (
        <div className={styles.placeholder} />
      )}
    </div>
  );
};

export default PokemonCard;

pokemon.module.css :- basic styling for pokemonCard.tsx file

/* Pokemon.module.css */

.card {
  width: 330px;
  height: 330px;
  position: relative;
  border-radius: 0.5rem;
  border: 1px solid gray;
  cursor: pointer;
  background: #f07878;
  overflow: hidden;
}

.card:hover {
  background: #f56565;
}

.name {
  font-weight: 700;
  font-size: 1.4rem;
  position: absolute;
  bottom: 10px;
  left: 10px;
  background: rgba(255, 255, 255, 0.8);
  border-radius: 0.5rem;
  padding: 5px;
  z-index: 10;
  width: 90%;
}

.image {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity 0.5s ease-in-out;
}

.placeholder {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: gray;
}

App.tsx :- Top level App component contains whole application

import "./App.css";
import PokemonCard from "./PokemonCard";

function App() {
  return (
    <div>
      <div className="container">
        <PokemonCard
          name={"bulbasaur"}
          image="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/1.svg"
        />
        <PokemonCard
          name={"ivysaur"}
          image="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/2.svg"
        />
        <PokemonCard
          name={"venusaur"}
          image="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/3.svg"
        />
        <PokemonCard
          name={"charmander"}
          image="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/4.svg"
        />
        <PokemonCard
          name={"charmeleon"}
          image="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/5.svg"
        />
        <PokemonCard
          name={"charizard"}
          image="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/6.svg"
        />
        <PokemonCard
          name={"squirtle"}
          image="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/7.svg"
        />
        <PokemonCard
          name={"wartortle"}
          image="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/8.svg"
        />
      </div>
    </div>
  );
}

export default App;

index.css :- global styles

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

a {
  font-weight: 500;
  color: #646cff;
  text-decoration: inherit;
}
a:hover {
  color: #535bf2;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

h1 {
  font-size: 3.2em;
  line-height: 1.1;
}

button {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;
}
button:hover {
  border-color: #646cff;
}
button:focus,
button:focus-visible {
  outline: 4px auto -webkit-focus-ring-color;
}

@media (prefers-color-scheme: light) {
  :root {
    color: #213547;
    background-color: #ffffff;
  }
  a:hover {
    color: #747bff;
  }
  button {
    background-color: #f9f9f9;
  }
}

.container {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 3rem;
  flex-wrap: wrap;

}

Output:-

As you can see now the images are downloading only when they are in viewport which is called lazy loading.

We will add infinite scrolling in this app in the next part of this blog.
If you like it please follow me and keep reading 😊

0
Subscribe to my newsletter

Read articles from Savi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Savi
Savi