Un événement pour la position CSS position:sticky

Eric Bidelman

Résumé

Petit secret: vous n'aurez peut-être pas besoin d'événements scroll dans votre prochaine application. À l'aide d'un IntersectionObserver, je vous montre comment déclencher un événement personnalisé lorsque les éléments position:sticky sont corrigés ou qu'ils ne restent plus conservés. Le tout sans utiliser d'écouteurs de défilement. Une démonstration très intéressante permet même de le prouver:

Afficher la démonstration | Source

Présentation de l'événement sticky-change

L'une des limites pratiques de l'utilisation de la position persistante CSS est qu'elle ne fournit pas de signal de plate-forme permettant de savoir quand la propriété est active. En d'autres termes, il n'y a aucun événement permettant de savoir quand un élément devient collant ou quand il cesse d'être collant.

Prenons l'exemple suivant, qui fixe un <div class="sticky"> à 10 px du haut de son conteneur parent:

.sticky {
  position: sticky;
  top: 10px;
}

Ne serait-il pas intéressant que le navigateur le prévienne lorsque les éléments atteignent cet objectif ? Je ne suis pas le seul à le penser. Un signal pour position:sticky pourrait débloquer de nombreux cas d'utilisation:

  1. Appliquez une ombre projetée à une bannière au fur et à mesure qu’elle colle.
  2. Lorsque l'utilisateur parcourt votre contenu, enregistrez les appels analytiques pour connaître sa progression.
  3. Lorsque l'utilisateur fait défiler la page, mettez à jour un widget flottant de la table des matières pour qu'il corresponde à la section actuelle.

En gardant ces cas d'utilisation à l'esprit, nous avons élaboré un objectif final: créer un événement qui se déclenche lorsqu'un élément position:sticky est corrigé. Appelons-le l'événement sticky-change:

document.addEventListener('sticky-change', e => {
  const header = e.detail.target;  // header became sticky or stopped sticking.
  const sticking = e.detail.stuck; // true when header is sticky.
  header.classList.toggle('shadow', sticking); // add drop shadow when sticking.

  document.querySelector('.who-is-sticking').textContent = header.textContent;
});

La démonstration utilise cet événement pour en-tête une ombre projetée lorsqu'elle est corrigée. Elle met également à jour le nouveau titre en haut de la page.

Dans la démonstration, les effets sont appliqués sans événement de défilement.

Effets de défilement sans événement de défilement ?

Structure de la page.
Structure de la page

Lançons-nous quelques termes afin que je puisse faire référence à ces noms dans la suite de cet article:

  1. Conteneur à défilement : zone de contenu (fenêtre d'affichage visible) contenant la liste des "articles de blog".
  2. Headers (En-têtes) : titre bleu dans chaque section contenant position:sticky.
  3. Sections persistantes : chaque section de contenu Le texte qui défile sous les en-têtes persistants.
  4. "Mode persistant" : lorsque position:sticky s'applique à l'élément.

Pour savoir quel en-tête passe en "mode persistant", nous devons trouver un moyen de déterminer le décalage de défilement du conteneur de défilement. Cela nous donnerait un moyen de calculer l'en-tête actuellement affiché. Cependant, cela devient assez difficile à faire sans les événements scroll. L'autre problème est que position:sticky supprime l'élément de la mise en page lorsqu'il est corrigé.

Ainsi, sans les événements de défilement, nous n'avons plus la possibilité d'effectuer des calculs liés à la mise en page sur les en-têtes.

Ajout d'un DOM factice pour déterminer la position de défilement

Au lieu des événements scroll, nous allons utiliser un IntersectionObserver pour déterminer quand les headers entrent en mode persistant et quand ils le quittent. L'ajout de deux nœuds (appelés "sentinels") dans chaque section persistante, l'un en haut et l'autre en bas, servira de points de cheminement pour déterminer la position de défilement. Lorsque ces repères entrent dans le conteneur et le quittent, leur visibilité change et l'observateur d'intersection déclenche un rappel.

Les éléments sentinelles ne s&#39;affichent pas.
Éléments sentinelles cachés.

Nous avons besoin de deux sentinelles pour couvrir quatre cas de défilement vers le haut et vers le bas:

  1. Défilement vers le bas : l'en-tête devient persistant lorsque sa sentinelle supérieure traverse la partie supérieure du conteneur.
  2. Défilement vers le bas : l'option header quitte le mode persistant lorsqu'elle atteint le bas de la section et que sa sentinelle inférieure traverse le haut du conteneur.
  3. Défilement vers le haut : l'en-tête quitte le mode persistant lorsque sa sentinelle supérieure revient à l'écran à partir du haut.
  4. Défilement vers le haut : l'en-tête devient persistant à mesure que la sentinelle inférieure revient dans la vue à partir du haut.

Il est utile de voir un enregistrement d'écran de 1 à 4 dans l'ordre:

Les observateurs d'intersection déclenchent des rappels lorsque les sentinelles entrent dans le conteneur de défilement ou la quittent.

Le CSS

Les sentinelles sont positionnées en haut et en bas de chaque section. .sticky_sentinel--top se trouve en haut de l'en-tête, tandis que .sticky_sentinel--bottom se trouve en bas de la section:

La sentinelle inférieure atteint son seuil.
Position des éléments sentinelles supérieurs et inférieurs.
:root {
  --default-padding: 16px;
  --header-height: 80px;
}
.sticky {
  position: sticky;
  top: 10px; /* adjust sentinel height/positioning based on this position. */
  height: var(--header-height);
  padding: 0 var(--default-padding);
}
.sticky_sentinel {
  position: absolute;
  left: 0;
  right: 0; /* needs dimensions */
  visibility: hidden;
}
.sticky_sentinel--top {
  /* Adjust the height and top values based on your on your sticky top position.
  e.g. make the height bigger and adjust the top so observeHeaders()'s
  IntersectionObserver fires as soon as the bottom of the sentinel crosses the
  top of the intersection container. */
  height: 40px;
  top: -24px;
}
.sticky_sentinel--bottom {
  /* Height should match the top of the header when it's at the bottom of the
  intersection container. */
  height: calc(var(--header-height) + var(--default-padding));
  bottom: 0;
}

Configurer les observateurs d'intersection

Les observateurs d'intersection observent de manière asynchrone les modifications apportées à l'intersection d'un élément cible et de la fenêtre d'affichage du document ou d'un conteneur parent. Dans notre cas, nous observons des intersections avec un conteneur parent.

La recette magique est IntersectionObserver. Chaque sentinelle reçoit un IntersectionObserver pour observer la visibilité de l'intersection dans le conteneur de défilement. Lorsqu'une sentinelle défile jusqu'à la fenêtre d'affichage visible, nous savons qu'un en-tête est résolu ou a cessé d'être persistant. De même, lorsqu'une sentinelle quitte la fenêtre d'affichage.

Tout d'abord, je configure des observateurs pour les sentinels d'en-tête et de pied de page:

/**
 * Notifies when elements w/ the `sticky` class begin to stick or stop sticking.
 * Note: the elements should be children of `container`.
 * @param {!Element} container
 */
function observeStickyHeaderChanges(container) {
  observeHeaders(container);
  observeFooters(container);
}

observeStickyHeaderChanges(document.querySelector('#scroll-container'));

Ensuite, j'ai ajouté un observateur à déclencher lorsque les éléments .sticky_sentinel--top passent par la partie supérieure du conteneur de défilement (dans les deux sens). La fonction observeHeaders crée les sentinelles supérieures et les ajoute à chaque section. L'observateur calcule l'intersection de la sentinelle avec le haut du conteneur, et décide s'il entre dans la fenêtre d'affichage ou la quitte. Ces informations déterminent si l'en-tête de section est persistant ou non.

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--top` become visible/invisible at the top of the container.
 * @param {!Element} container
 */
function observeHeaders(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;

      // Started sticking.
      if (targetInfo.bottom < rootBoundsInfo.top) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.bottom >= rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
       fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [0], root: container});

  // Add the top sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--top');
  sentinels.forEach(el => observer.observe(el));
}

L'observateur est configuré avec threshold: [0] afin que son rappel se déclenche dès que la sentinelle devient visible.

Le processus est similaire pour la sentinelle inférieure (.sticky_sentinel--bottom). Un deuxième observateur est créé pour se déclencher lorsque les pieds de page passent par la partie inférieure du conteneur à défilement. La fonction observeFooters crée les nœuds sentinels et les associe à chaque section. L'observateur calcule l'intersection de la sentinelle avec le bas du conteneur, et décide s'il entre ou part. Ces informations déterminent si l'en-tête de section est collé ou non.

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--bottom` become visible/invisible at the bottom of the
 * container.
 * @param {!Element} container
 */
function observeFooters(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;
      const ratio = record.intersectionRatio;

      // Started sticking.
      if (targetInfo.bottom > rootBoundsInfo.top && ratio === 1) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.top < rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
        fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [1], root: container});

  // Add the bottom sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--bottom');
  sentinels.forEach(el => observer.observe(el));
}

L'observateur est configuré avec threshold: [1] afin que son rappel se déclenche lorsque le nœud entier est visible.

Enfin, mes deux utilitaires permettent de déclencher l'événement personnalisé sticky-change et de générer les sentinelles:

/**
 * @param {!Element} container
 * @param {string} className
 */
function addSentinels(container, className) {
  return Array.from(container.querySelectorAll('.sticky')).map(el => {
    const sentinel = document.createElement('div');
    sentinel.classList.add('sticky_sentinel', className);
    return el.parentElement.appendChild(sentinel);
  });
}

/**
 * Dispatches the `sticky-event` custom event on the target element.
 * @param {boolean} stuck True if `target` is sticky.
 * @param {!Element} target Element to fire the event on.
 */
function fireEvent(stuck, target) {
  const e = new CustomEvent('sticky-change', {detail: {stuck, target}});
  document.dispatchEvent(e);
}

Et voilà !

Démonstration finale

Nous avons créé un événement personnalisé lorsque les éléments avec position:sticky sont corrigés et des effets de défilement ajoutés sans utiliser d'événements scroll.

Afficher la démonstration | Source

Conclusion

Je me suis souvent demandé si IntersectionObserver serait un outil utile pour remplacer certains des modèles d'interface utilisateur basés sur des événements scroll qui se sont développés au fil des années. Il s'avère que la réponse est "oui" et "non". La sémantique de l'API IntersectionObserver rend son utilisation difficile pour tout. Mais comme je l'ai montré ici, vous pouvez l'utiliser pour quelques techniques intéressantes.

Vous pouvez aussi détecter les changements de style ?

Pas vraiment. Nous avions besoin d'un moyen d'observer les changements de style d'un élément DOM. Malheureusement, rien dans les API de la plate-forme Web ne vous permet de surveiller les changements de style.

Un élément MutationObserver est un choix logique, mais cela ne fonctionne pas dans la plupart des cas. Par exemple, dans la version de démonstration, nous recevons un rappel lorsque la classe sticky est ajoutée à un élément, mais pas lorsque le style calculé de l'élément change. Rappelez-vous que la classe sticky a déjà été déclarée lors du chargement de la page.

À l'avenir, une extension Style Mutation Observer (Observateur de mutation de style) pourra être utile pour observer les modifications apportées aux styles calculés d'un élément. position: sticky.