Pourquoi éviter les écouteurs de scroll classiques ?
Pendant longtemps, détecter qu’un élément entrait dans le viewport passait par une recette assez fragile : écouter l’événement scroll, appeler getBoundingClientRect() sur une série d’éléments, puis décider quoi faire selon leur position.
Cela fonctionne, mais cette approche devient vite coûteuse. Le scroll est un événement très fréquent. Si vous mesurez trop souvent la mise en page, vous pouvez forcer le navigateur à recalculer des positions, créer des saccades et dégrader l’expérience utilisateur. Même avec un throttle ou un requestAnimationFrame, vous restez responsable d’une logique bas niveau.
L’API IntersectionObserver propose une approche plus déclarative : vous indiquez au navigateur quels éléments observer, par rapport à quel conteneur, et à partir de quel seuil de visibilité vous souhaitez être notifié. Le navigateur optimise ensuite les calculs.
C’est particulièrement utile pour le lazy loading avancé, les animations au scroll, les listes infinies, la mesure d’exposition de composants ou le préchargement progressif. Si votre objectif est surtout d’optimiser les médias, l’article sur l’optimisation des images web complète bien ce sujet.
Le principe de base
Un IntersectionObserver reçoit une fonction de rappel appelée quand un élément observé croise une zone de référence. Par défaut, cette zone est le viewport.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log('Visible :', entry.target)
}
}
})
const element = document.querySelector('[data-observe]')
if (element) {
observer.observe(element)
}
La fonction reçoit un tableau d’IntersectionObserverEntry. Chaque entrée décrit l’état d’intersection d’un élément :
target: l’élément observé ;isIntersecting: indique s’il croise la zone observée ;intersectionRatio: proportion visible de l’élément ;boundingClientRect: rectangle de l’élément ;intersectionRect: portion réellement visible ;rootBounds: rectangle de la zone de référence.
La grande différence avec un gestionnaire de scroll manuel, c’est que vous ne demandez pas constamment la position de chaque élément. Vous réagissez à des changements significatifs.
Configurer root, rootMargin et threshold
Le constructeur accepte un second argument de configuration.
const observer = new IntersectionObserver(handleIntersect, {
root: null,
rootMargin: '0px 0px -20% 0px',
threshold: 0.25
})
root définit le conteneur de référence. Avec null, le viewport est utilisé. Vous pouvez aussi observer la visibilité d’éléments dans un conteneur scrollable.
rootMargin ajoute ou retire une marge virtuelle autour de la zone observée. Une marge positive permet de déclencher une action avant que l’élément soit visible. Une marge négative retarde le déclenchement.
threshold définit le ou les seuils de visibilité. Avec 0.25, le callback se déclenche quand environ 25 % de l’élément devient visible ou cesse de l’être. Vous pouvez aussi fournir un tableau.
const observer = new IntersectionObserver(handleIntersect, {
threshold: [0, 0.25, 0.5, 0.75, 1]
})
Cette granularité est utile pour suivre une progression de lecture ou déclencher des effets selon le niveau de visibilité.
Exemple pratique : révéler des éléments au scroll
Un cas d’usage fréquent consiste à ajouter une classe CSS quand un bloc entre dans le viewport.
<section class="reveal" data-reveal>
<h2>Une section progressive</h2>
<p>Ce contenu apparaît quand il devient visible.</p>
</section>
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 250ms ease, transform 250ms ease;
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.reveal {
transition: none;
transform: none;
}
}
const elements = document.querySelectorAll<HTMLElement>('[data-reveal]')
const observer = new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
entry.target.classList.add('is-visible')
observer.unobserve(entry.target)
}
},
{
threshold: 0.2
}
)
for (const element of elements) {
observer.observe(element)
}
Deux détails sont importants ici. D’abord, on appelle unobserve après la première apparition pour éviter de continuer à surveiller un élément qui n’a plus besoin de l’être. Ensuite, la media query prefers-reduced-motion respecte les utilisateurs qui ont demandé moins d’animations dans leur système.
Pour des transitions plus ambitieuses entre états d’interface, vous pouvez aussi regarder la View Transitions API, qui répond à un autre besoin : animer les changements de page ou de structure.
Lazy loading contrôlé avec data-src
Le lazy loading natif via loading="lazy" est souvent suffisant pour les images simples. Mais IntersectionObserver reste utile quand vous voulez contrôler précisément le chargement d’un composant, d’une vidéo, d’un graphique ou d’un module JavaScript.
<img
data-lazy-image
data-src="/images/dashboard-large.avif"
src="/images/dashboard-placeholder.jpg"
alt="Tableau de bord analytique"
width="1200"
height="800"
/>
const images = document.querySelectorAll<HTMLImageElement>('[data-lazy-image]')
const imageObserver = new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const image = entry.target as HTMLImageElement
const source = image.dataset.src
if (source) {
image.src = source
image.removeAttribute('data-src')
}
observer.unobserve(image)
}
},
{
rootMargin: '300px 0px'
}
)
for (const image of images) {
imageObserver.observe(image)
}
La marge de 300px permet de démarrer le chargement avant l’arrivée réelle de l’image dans le viewport. C’est souvent préférable à un déclenchement exact au dernier moment, qui peut produire un affichage tardif.
Infinite scroll : observer une sentinelle
Pour charger une page de résultats supplémentaire, il est inutile d’observer chaque élément de la liste. Une technique plus robuste consiste à placer une sentinelle à la fin de la liste.
<ul id="results"></ul>
<div id="sentinel" aria-hidden="true"></div>
let page = 1
let isLoading = false
let hasMore = true
async function loadNextPage() {
if (isLoading || !hasMore) return
isLoading = true
try {
const response = await fetch(`/api/articles?page=${page}`)
const data: { items: string[]; hasMore: boolean } = await response.json()
renderItems(data.items)
hasMore = data.hasMore
page += 1
} finally {
isLoading = false
}
}
const sentinel = document.querySelector('#sentinel')
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
void loadNextPage()
}
}, {
rootMargin: '600px 0px'
})
if (sentinel) {
observer.observe(sentinel)
}
Cette approche se combine très bien avec AbortController si vous devez annuler une requête lors d’un changement de filtre ou de page. J’ai détaillé cette logique dans l’article consacré à AbortController. Pour éviter de recharger les mêmes données inutilement, vous pouvez aussi mettre en place une stratégie comme celle décrite dans l’article sur le cache frontend.
Utilisation dans un composant React
Dans React, il est préférable d’encapsuler l’observation dans un hook ou dans un useEffect local. Voici un exemple simple pour détecter la visibilité d’un élément.
import { useEffect, useRef, useState } from 'react'
export function VisibilityCard() {
const ref = useRef<HTMLDivElement | null>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
setVisible(entry.isIntersecting)
},
{ threshold: 0.3 }
)
observer.observe(element)
return () => {
observer.disconnect()
}
}, [])
return (
<article ref={ref} className={visible ? 'card card--visible' : 'card'}>
<h2>Carte observée</h2>
<p>Son état dépend de sa visibilité réelle.</p>
</article>
)
}
Le nettoyage avec disconnect() est essentiel. Sans lui, vous risquez de conserver des observateurs inutiles après le démontage du composant.
Pièges fréquents
Le premier piège consiste à déclencher trop de travail dans le callback. Même si l’API est optimisée, votre code peut rester coûteux. Évitez les calculs lourds, les mutations massives du DOM et les appels réseau non contrôlés. Pour des traitements réellement intensifs, les Web Workers restent une meilleure solution.
Le deuxième piège est de confondre visibilité technique et visibilité perçue. Un élément peut être considéré comme intersectant tout en étant masqué par un overlay, une opacité nulle ou une transformation complexe. IntersectionObserver mesure une intersection géométrique, pas une garantie d’attention utilisateur.
Le troisième piège concerne l’accessibilité. Une animation déclenchée au scroll ne doit pas être indispensable pour comprendre la page. Le contenu doit rester présent dans le DOM, lisible par les technologies d’assistance, et utilisable sans animation.
Bonnes pratiques de production
Utilisez un nombre limité d’observateurs. Dans beaucoup de cas, un seul observateur peut surveiller plusieurs éléments partageant la même configuration.
Désabonnez les éléments qui n’ont plus besoin d’être observés avec unobserve. Pour nettoyer tout l’observateur, utilisez disconnect.
Choisissez un rootMargin adapté au cas d’usage. Pour du préchargement, anticipez largement. Pour de l’animation visuelle, un seuil plus proche de l’apparition réelle est souvent préférable.
Enfin, prévoyez un fallback simple si votre cible navigateur l’exige. Aujourd’hui, l’API est largement disponible, mais un rendu sans animation ou un chargement immédiat des contenus critiques reste souvent acceptable.
Conclusion
IntersectionObserver est une API discrète mais fondamentale pour construire des interfaces modernes performantes. Elle évite de surveiller manuellement le scroll, réduit les risques de recalculs inutiles et permet de déclencher des comportements au bon moment.
Son intérêt ne se limite pas aux effets visuels. Bien utilisée, elle structure des fonctionnalités concrètes : lazy loading, infinite scroll, préchargement, mesure d’exposition et activation progressive de composants. Comme souvent en frontend, la clé n’est pas seulement de connaître l’API, mais de l’intégrer dans une architecture sobre, mesurable et accessible.