---
title: Follow elements visibility with Intersection Observer
date: 2026-03-03
draft: false
description: A quick look at the Intersection Observer API, which allows to follow the visibility of an element within a page or a container
categories:
  - javascript
---

import {
  DefaultIntersection,
  RootMargin100px,
  RootMargin,
  RootMarginTopBottom,
  Threshold,
  Thresholds,
} from "@/components/IntersectionObserverDemo";

[Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API), in Javascript, allows to follow the visibility of an element within a page or a container.

## Problem

When we scroll through a page, we scroll its content (generally, vertically).

On the right of our screen, a scrollbar indicates our progress in the page.

In a web page, if we wanted to follow the visibility of some elements, we could listen to the `scroll` event to react to the page scrolling :

```js
window.addEventListener("scroll", () => {
  const scrollPosition = window.scrollY;

  // perform actions based on the position,
  // on what is visible
});
```

This approach can work, but what is the problem ?

- The event listener will run continuously, when scrolling, which could overload the main thread
- To figure out if an element of the page is visible, we will retrieve its position (with `getBoundingClientRect()` for example) compare it to the scroll position in the page, so we have to manually calculate it
- If we want to determine the visibility of an element within a [scrollable container](https://developer.mozilla.org/en-US/docs/Glossary/Scroll_container) in the page, then we also have to manually calculate it, based on the position of the parent element

## Intersection Observer

[Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) naturally handles these problems.

Starting from a **root element**, it will **observe** elements within a defined **intersection zone**.

When any of the observed elements **enters** or **exits** the intersection zone, then we can execute a callback.

### Root element

When creating an instance of `IntersectionObserver`, we can pass it options.

Among these options, we can specify a root element : `root`. This element, if not specified, will be the entire page. Otherwise, it can be any scrollable container in which we want to monitor the visibility of some elements.

```js
const observer = new IntersectionObserver(
  callback, // callback function
  {
    // root element
    root: document.getElementById("container"),
  },
);
```

### Callback function

When creating an instance of `IntersectionObserver`, we register a callback function.

This function will be executed in two situations :

- When the instance is created
- When an observed element is considered as entering or exiting the intersection zone

It takes two parameters : elements entering or exiting the intersection zone at the moment, and the actual instance of `IntersectionObserver` :

```js
const observer = new IntersectionObserver(
  (entries, observer) => {},
  // options...
);
```

:::important

The advantage here is that the function will not be called continuously, when scrolling the page, but only when one or more elements enter or exit the intersection zone.

Based on their visibility, we can then execute one or more actions as a consequence of the status change.

:::

Inside `entries`, we have an array of elements, each of type [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry). We can then loop through them and read useful information, such as :

- `isIntersecting`, the most convenient : is the element in the intersection zone ?
- `intersectionRatio`, can be useful : what percentage of the element is crossing with the root element ?

### Observing elements

It's very simple to observe an element. Once the instance of `IntersectionObserver` is created, we call its `observe` method by passing the element to observe :

```js
observer.observe(element);
```

Then, as soon as the element crosses the intersection zone, the callback function is triggered.

### Intersection zone

By default, the intersection zone covers the entire root element. As soon as an observed element touches this zone, it will be considered as visible.

```js
const observer = new IntersectionObserver(callback);
```

<DefaultIntersection client:visible />

In the example above, as soon as the elements enter the zone, they are considered as visible. So it feels like all elements are always visible.

### `rootMargin`

It is possible to make the intersection zone bigger or smaller by passing an option to the instance of `IntersectionObserver` : `rootMargin`.

The `rootMargin` option accepts the same syntax as the CSS `margin` property : `top right bottom left`. We can therefore indicate one to four values, in pixels, percentage, or both.

:::important

Negative values will make the zone smaller, while positive values will make it bigger.

:::

Let's look at an example with `rootMargin` to `-100px`, which reduces the intersection zone by 100 pixels on all sides :

```js
const observer = new IntersectionObserver(callback, {
  rootMargin: "-100px",
});
```

<RootMargin100px client:visible />

Try it yourself with the slider below. We want to reduce the intersection zone, so we will apply a negative value :

<RootMargin client:visible />

### `threshold`

The `threshold` option allows to add a threshold value, from which the element will be considered as visible. The value can vary :

- A number between `0` and `1` is equivalent to a percentage between `0%` and `100%`. For example if we pass `0.8`, then the element is considered visible when at least 80% of its content is visible

Example with `0.8` :

```js
const observer = new IntersectionObserver(callback, {
  threshold: 0.8,
});
```

<Threshold client:visible />

- An array of values between `0` and `1`, indicating to the instance of `IntersectionObserver` at what percentage of the target's visibility the observer's callback should be executed

Example with a call every 10%, which displays the visibility percentage of the element (`intersectionRatio`) :

```js
const observer = new IntersectionObserver(callback, {
  threshold: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
});
```

<Thresholds client:visible />

## Handling a table of contents

An example of Intersection Observer API usage is handling a table of contents.

As we read the article, the current section is highlighted.

This is exactly what [Astro Starlight](https://starlight.astro.build/) does : when a section title enters the intersection zone, the corresponding table of contents element is highlighted.

Astro Starlight contains a [web component](https://github.com/withastro/starlight/blob/main/packages/starlight/components/TableOfContents/starlight-toc.ts). This component contains the [creation of the `IntersectionObserver` instance](https://github.com/withastro/starlight/blob/main/packages/starlight/components/TableOfContents/starlight-toc.ts#L97).

We will also find the intersection zone calculation with the [`rootMargin`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/TableOfContents/starlight-toc.ts#L114) option : we take the height of the navbar, we add the height of the table of contents on mobile, and we have the beginning of the intersection zone. 53 pixels later, this is the end of the zone. When a title crosses this zone, the corresponding table of contents element will change its appearance.

Astro Starlight actually defines two web components : one for [mobile](https://github.com/withastro/starlight/blob/main/packages/starlight/components/MobileTableOfContents.astro) (displayed at the top of the screen), and one for larger screens (displayed on the right).
