Construire un en-tête dynamique avec Intersection Observer
Publié: 2022-03-10L'API Intersection Observer est une API JavaScript qui nous permet d'observer un élément et de détecter quand il passe un point spécifié dans un conteneur de défilement - souvent (mais pas toujours) la fenêtre d'affichage - déclenchant une fonction de rappel.
Intersection Observer peut être considéré comme plus performant que l'écoute des événements de défilement sur le thread principal, car il est asynchrone et le rappel ne se déclenchera que lorsque l'élément que nous observons atteint le seuil spécifié, à chaque fois que la position de défilement est mise à jour. Dans cet article, nous allons parcourir un exemple de la façon dont nous pouvons utiliser Intersection Observer pour créer un composant d'en-tête fixe qui change lorsqu'il croise différentes sections de la page Web.
Utilisation de base
Pour utiliser Intersection Observer, nous devons d'abord créer un nouvel observateur, qui prend deux paramètres : un objet avec les options de l'observateur et la fonction de rappel que nous voulons exécuter chaque fois que l'élément que nous observons (appelé la cible de l'observateur) se croise avec la racine (le conteneur de défilement, qui doit être un ancêtre de l'élément cible).
const options = { root: document.querySelector('[data-scroll-root]'), rootMargin: '0px', threshold: 1.0 } const callback = (entries, observer) => { entries.forEach((entry) => console.log(entry)) } const observer = new IntersectionObserver(callback, options)
Lorsque nous avons créé notre observateur, nous devons lui demander de surveiller un élément cible :
const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)
Toutes les valeurs d'options peuvent être omises, car elles reviendront à leurs valeurs par défaut :
const options = { rootMargin: '0px', threshold: 1.0 }
Si aucune racine n'est spécifiée, elle sera classée comme la fenêtre d'affichage du navigateur. L'exemple de code ci-dessus montre les valeurs par défaut pour rootMargin
et threshold
. Ceux-ci peuvent être difficiles à visualiser, il vaut donc la peine de les expliquer :
rootMargin
La valeur rootMargin
est un peu comme ajouter des marges CSS à l'élément racine - et, tout comme les marges, peut prendre plusieurs valeurs, y compris des valeurs négatives. L'élément cible sera considéré comme sécant par rapport aux marges.
Cela signifie qu'un élément peut techniquement être classé comme "sécant" même lorsqu'il est hors de vue (si notre racine de défilement est la fenêtre).
rootMargin
défaut est 0px
, mais peut prendre une chaîne composée de plusieurs valeurs, tout comme l'utilisation de la propriété margin
en CSS.
threshold
Le threshold
peut être constitué d'une valeur unique ou d'un tableau de valeurs comprises entre 0 et 1. Il représente la proportion de l'élément qui doit se trouver dans les limites de la racine pour qu'il soit considéré comme sécant . En utilisant la valeur par défaut de 1, le rappel se déclenche lorsque 100 % de l'élément cible est visible dans la racine.
Il n'est pas toujours facile de visualiser quand un élément sera classé comme visible à l'aide de ces options. J'ai construit un petit outil pour aider à se familiariser avec Intersection Observer.
Création de l'en-tête
Maintenant que nous avons compris les principes de base, commençons à construire notre en-tête dynamique. Nous allons commencer avec une page Web divisée en sections. Cette image montre la mise en page complète de la page que nous allons créer :
J'ai inclus une démo à la fin de cet article, alors n'hésitez pas à y accéder directement si vous souhaitez décocher le code. (Il existe également un référentiel Github.)
Chaque section a une hauteur minimale de 100vh
(bien qu'elles puissent être plus longues, selon le contenu). Notre en-tête est fixé en haut de la page et reste en place lorsque l'utilisateur fait défiler (en utilisant position: fixed
). Les sections ont des arrière-plans de couleurs différentes, et lorsqu'elles rencontrent l'en-tête, les couleurs de l'en-tête changent pour compléter celles de la section. Il y a aussi un marqueur pour montrer la section actuelle dans laquelle se trouve l'utilisateur, qui glisse lorsque la section suivante arrive. Pour nous permettre d'accéder plus facilement au code pertinent, j'ai mis en place une démo minimale avec notre point de départ (avant de commencer à utiliser l'API Intersection Observer), au cas où vous souhaiteriez suivre.
Balisage
Nous allons commencer par le code HTML de notre en-tête. Cela va être un en-tête assez simple avec un lien d'accueil et une navigation, rien de particulièrement fantaisiste, mais nous allons utiliser quelques attributs de données : data-header
pour l'en-tête lui-même (afin que nous puissions cibler l'élément avec JS) , et trois liens d'ancrage avec l'attribut data-link
, qui fera défiler l'utilisateur jusqu'à la section appropriée lorsqu'il clique dessus :
<header data-header> <nav class="header__nav"> <div class="header__left-content"> <a href="#0">Home</a> </div> <ul class="header__list"> <li> <a href="#about-us" data-link>About us</a> </li> <li> <a href="#flavours" data-link>The flavours</a> </li> <li> <a href="#get-in-touch" data-link>Get in touch</a> </li> </ul> </nav> </header>
Ensuite, le HTML pour le reste de notre page, qui est divisé en sections. Par souci de concision, je n'ai inclus que les parties pertinentes pour l'article, mais le balisage complet est inclus dans la démo. Chaque section comprend un attribut de données spécifiant le nom de la couleur d'arrière-plan et un id
correspondant à l'un des liens d'ancrage dans l'en-tête :
<main> <section data-section="raspberry"> <!--Section content--> </section> <section data-section="mint"> <!--Section content--> </section> <section data-section="vanilla"> <!--Section content--> </section> <section data-section="chocolate"> <!--Section content--> </section> </main>
Nous allons positionner notre en-tête avec CSS afin qu'il reste fixe en haut de la page lorsque l'utilisateur défile :
header { position: fixed; width: 100%; }
Nous donnerons également à nos sections une hauteur minimale et centrerons le contenu. (Ce code n'est pas nécessaire pour que Intersection Observer fonctionne, c'est juste pour la conception.)
section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }
Avertissement iframe
Lors de la construction de cette démo Codepen, j'ai rencontré un problème déroutant où mon code Intersection Observer qui aurait dû fonctionner parfaitement ne parvenait pas à déclencher le rappel au bon point de l'intersection, mais se déclenchait à la place lorsque l'élément cible croisait le bord de la fenêtre. Après un peu de réflexion, j'ai réalisé que c'était parce que dans Codepen, le contenu est chargé dans un iframe, qui est traité différemment. (Voir la section de la documentation MDN sur le découpage et le rectangle d'intersection pour plus de détails.)
Comme solution de contournement, dans la démo, nous pouvons envelopper notre balisage dans un autre élément, qui agira comme le conteneur de défilement - la racine de nos options IO - plutôt que la fenêtre d'affichage du navigateur, comme on pourrait s'y attendre :
<div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>
Si vous voulez voir comment utiliser la fenêtre d'affichage comme racine à la place pour la même démo, cela est inclus dans le référentiel Github.
CSS
Dans notre CSS, nous définirons des propriétés personnalisées pour les couleurs que nous utilisons. Nous allons également définir deux propriétés personnalisées supplémentaires pour le texte d'en-tête et les couleurs d'arrière-plan, et définir certaines valeurs initiales. (Nous allons mettre à jour ces deux propriétés personnalisées pour les différentes sections ultérieurement.)
:root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }
Nous utiliserons ces propriétés personnalisées dans notre en-tête :
header { background-color: var(--headerBg); color: var(--headerText); }
Nous définirons également les couleurs de nos différentes sections. J'utilise les attributs de données comme sélecteurs, mais vous pouvez tout aussi bien utiliser une classe si vous préférez.
[data-section="raspberry"] { background-color: var(--raspberry); color: var(--vanilla); } [data-section="mint"] { background-color: var(--mint); color: var(--chocolate); } [data-section="vanilla"] { background-color: var(--vanilla); color: var(--chocolate); } [data-section="chocolate"] { background-color: var(--chocolate); color: var(--vanilla); }
Nous pouvons également définir des styles pour notre en-tête lorsque chaque section est en vue :
/* Header */ [data-theme="raspberry"] { --headerText: var(--raspberry); --headerBg: var(--vanilla); } [data-theme="mint"] { --headerText: var(--mint); --headerBg: var(--chocolate); } [data-theme="chocolate"] { --headerText: var(--chocolate); --headerBg: var(--vanilla); }
Il y a un cas plus fort pour utiliser les attributs de données ici parce que nous allons basculer l'attribut data-theme
de l'en-tête à chaque intersection.
Création de l'observateur
Maintenant que nous avons le HTML et le CSS de base pour la configuration de notre page, nous pouvons créer un observateur à surveiller pour chacune de nos sections qui apparaissent. Nous souhaitons déclencher un rappel chaque fois qu'une section entre en contact avec le bas de l'en-tête lorsque nous faisons défiler la page. Cela signifie que nous devons définir une marge racine négative qui correspond à la hauteur de l'en-tête.
const header = document.querySelector('[data-header]') const sections = [...document.querySelectorAll('[data-section]')] const scrollRoot = document.querySelector('[data-scroller]') const options = { root: scrollRoot, rootMargin: `${header.offsetHeight * -1}px`, threshold: 0 }
Nous fixons un seuil de 0 , car nous voulons qu'il se déclenche si une partie de la section croise la marge racine.
Tout d'abord, nous allons créer un rappel pour modifier la valeur data-theme
de l'en-tête. (C'est plus simple que d'ajouter et de supprimer des classes, en particulier lorsque notre élément d'en-tête peut avoir d'autres classes appliquées.)
/* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }
Ensuite, nous allons créer l'observateur pour surveiller les sections qui se croisent :
/* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })
Nous devrions maintenant voir la mise à jour des couleurs de notre en-tête lorsque chaque section rencontre l'en-tête.
Cependant, vous remarquerez peut-être que les couleurs ne se mettent pas à jour correctement lorsque nous faisons défiler vers le bas. En fait, l'en-tête se met à jour avec les couleurs de la section précédente à chaque fois ! En défilant vers le haut, en revanche, cela fonctionne parfaitement. Nous devons déterminer la direction de défilement et modifier le comportement en conséquence.
Trouver la direction de défilement
Nous allons définir une variable dans notre JS pour le sens de défilement, avec une valeur initiale de 'up'
, et une autre pour la dernière position de défilement connue ( prevYPosition
). Ensuite, dans le rappel, si la position de défilement est supérieure à la valeur précédente, nous pouvons définir la valeur de direction
sur 'down'
ou 'up'
si vice versa.
let direction = 'up' let prevYPosition = 0 const setScrollDirection = () => { if (scrollRoot.scrollTop > prevYPosition) { direction = 'down' } else { direction = 'up' } prevYPosition = scrollRoot.scrollTop } const onIntersect = (entries, observer) => { entries.forEach((entry) => { setScrollDirection() /* ... */ }) }
Nous allons également créer une nouvelle fonction pour mettre à jour les couleurs de l'en-tête, en passant la section cible en argument :
const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }
Jusqu'à présent, nous ne devrions voir aucun changement dans le comportement de notre en-tête. Mais maintenant que nous connaissons le sens de défilement, nous pouvons passer une cible différente pour notre fonction updateColors()
. Si le sens de défilement est vers le haut, nous utiliserons la cible d'entrée. S'il est en panne, nous utiliserons la section suivante (s'il y en a une).
const getTargetSection = (target) => { if (direction === 'up') return target if (target.nextElementSibling) { return target.nextElementSibling } else { return target } } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() const target = getTargetSection(entry.target) updateColors(target) }) }
Il y a cependant un autre problème : l'en-tête se met à jour non seulement lorsque la section atteint l'en-tête, mais également lorsque l'élément suivant apparaît en bas de la fenêtre. C'est parce que notre observateur déclenche le rappel deux fois : une fois lorsque l'élément entre, et une autre fois lorsqu'il sort.
Pour déterminer si l'en-tête doit être mis à jour, nous pouvons utiliser la clé isIntersecting
de l'objet d' entry
. Créons une autre fonction pour renvoyer une valeur booléenne indiquant si les couleurs de l'en-tête doivent être mises à jour :
const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }
Nous mettrons à jour notre fonction onIntersect()
en conséquence :
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() /* Do nothing if no need to update */ if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) }) }
Maintenant, nos couleurs devraient se mettre à jour correctement. Nous pouvons définir une transition CSS, pour que l'effet soit un peu plus agréable :
header { transition: background-color 200ms, color 200ms; }
Ajout du marqueur dynamique
Ensuite, nous ajouterons un marqueur à l'en-tête qui met à jour sa position lorsque nous faisons défiler les différentes sections. Nous pouvons utiliser un pseudo-élément pour cela, nous n'avons donc pas besoin d'ajouter quoi que ce soit à notre code HTML. Nous allons lui donner un style CSS simple pour le positionner en haut à gauche de l'en-tête et lui donner une couleur d'arrière-plan. Nous utilisons currentColor
pour cela, car il prendra la valeur de la couleur du texte d'en-tête :
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }
Nous pouvons utiliser une propriété personnalisée pour la largeur, avec une valeur par défaut de 0. Nous utiliserons également une propriété personnalisée pour la valeur translate x. Nous allons définir les valeurs de ceux-ci dans notre fonction de rappel au fur et à mesure que l'utilisateur défile.
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; width: var(--markerWidth, 0); background-color: currentColor; transform: translate3d(var(--markerLeft, 0), 0, 0); }
Nous pouvons maintenant écrire une fonction qui mettra à jour la largeur et la position du marqueur au point d'intersection :
const updateMarker = (target) => { const id = target.id /* Do nothing if no target ID */ if (!id) return /* Find the corresponding nav link, or use the first one */ let link = headerLinks.find((el) => { return el.getAttribute('href') === `#${id}` }) link = link || headerLinks[0] /* Get the values and set the custom properties */ const distanceFromLeft = link.getBoundingClientRect().left header.style.setProperty('--markerWidth', `${link.clientWidth}px`) header.style.setProperty('--markerLeft', `${distanceFromLeft}px`) }
Nous pouvons appeler la fonction en même temps que nous mettons à jour les couleurs :
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }
Nous devrons également définir une position initiale pour le marqueur, afin qu'il n'apparaisse pas de nulle part. Une fois le document chargé, nous appellerons la fonction updateMarker()
, en utilisant la première section comme cible :
document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })
Enfin, ajoutons une transition CSS pour que le marqueur glisse sur l'en-tête d'un lien à l'autre. Lorsque nous effectuons la transition de la propriété width
, nous pouvons utiliser will-change
pour permettre au navigateur d'effectuer des optimisations.
header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }
Défilement fluide
Pour une touche finale, ce serait bien si, lorsqu'un utilisateur clique sur un lien, il défile en douceur vers le bas de la page, au lieu de sauter à la section. De nos jours, nous pouvons le faire directement dans notre CSS, aucun JS requis ! Pour une expérience plus accessible, il est judicieux de respecter les préférences de mouvement de l'utilisateur en n'implémentant un défilement fluide que s'il n'a pas spécifié de préférence pour un mouvement réduit dans ses paramètres système :
@media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }
Démo finale
En rassemblant toutes les étapes ci-dessus, vous obtenez la démonstration complète.
Prise en charge du navigateur
Intersection Observer est largement pris en charge dans les navigateurs modernes. Si nécessaire, il peut être rempli pour les anciens navigateurs - mais je préfère adopter une approche d'amélioration progressive lorsque cela est possible. Dans le cas de notre en-tête, il ne serait pas très préjudiciable à l'expérience utilisateur de fournir une version simple et immuable pour les navigateurs non compatibles.
Pour détecter si Intersection Observer est pris en charge, nous pouvons utiliser ce qui suit :
if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { /* Code to execute if IO is supported */ } else { /* Code to execute if not supported */ }
Ressources
En savoir plus sur Intersection Observer :
- Documentation complète, avec quelques exemples pratiques de MDN
- Outil de visualisation Intersection Observer
- Visibilité des éléments de synchronisation avec l'API Intersection Observer - un autre tutoriel de MDN, qui examine comment IO peut être utilisé pour suivre la visibilité des publicités
- Cet article de Denys Mishunov couvre d'autres utilisations des IO, y compris les actifs de chargement différé. Bien que ce soit moins nécessaire maintenant (grâce à l'attribut de
loading
), il reste encore beaucoup à apprendre ici.