TL;DR
Voici un 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 fixés ou lorsqu'ils ne collent plus. Le tout sans utiliser d'écouteurs de défilement. Il existe même une excellente démonstration pour le prouver:
Présentation de l'événement sticky-change
L'une des limites pratiques de l'utilisation de la position sticky CSS est qu'elle ne fournit pas de signal de plate-forme pour savoir quand la propriété est active. En d'autres termes, il n'existe aucun événement pour savoir quand un élément devient persistant ou quand il cesse de l'être.
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 pratique que le navigateur indique quand les éléments atteignent cette marque ?
Apparemment, je ne suis pas le seul à le penser. Un signal pour position:sticky
pourrait débloquer un certain nombre de cas d'utilisation:
- Appliquez une ombre portée à une bannière lorsqu'elle se colle.
- Lorsque l'utilisateur lit votre contenu, enregistrez les appels Analytics pour connaître sa progression.
- Lorsque l'utilisateur fait défiler la page, mettez à jour un widget de sommaire flottant avec la section actuelle.
Compte tenu de ces cas d'utilisation, nous avons défini un objectif final: créer un événement qui se déclenche lorsqu'un élément position:sticky
est fixé. 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émo utilise cet événement pour ajouter une ombre portée aux en-têtes lorsqu'ils deviennent fixes. Le nouveau titre s'affiche également en haut de la page.
Des effets de défilement sans événements de défilement ?
Commençons par définir quelques termes afin que je puisse m'y référer tout au long de cet article:
- Conteneur à défilement : zone de contenu (fenêtre d'affichage visible) contenant la liste des "articles de blog".
- En-têtes : titre bleu dans chaque section contenant
position:sticky
. - Sections persistantes : chaque section de contenu. Texte qui défile sous les en-têtes persistants.
- Mode persistant : lorsque
position:sticky
s'applique à l'élément.
Pour savoir quel en-tête passe en mode "sticky", nous devons trouver un moyen de déterminer le décalage de défilement du conteneur de défilement. Cela nous permettrait de calculer l'en-tête actuellement affiché. Toutefois, cela devient assez difficile à faire sans événements scroll
:) L'autre problème est que position:sticky
supprime l'élément de la mise en page lorsqu'il devient fixe.
Par conséquent, sans événements de défilement, nous avons perdu 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 d'événements scroll
, nous allons utiliser un IntersectionObserver
pour déterminer quand les en-têtes entrent et sortent du mode persistant. L'ajout de deux nœuds (sentinelles) dans chaque section persistante, l'un en haut et l'autre en bas, servira de repères pour déterminer la position de défilement. Lorsque ces repères entrent et sortent du conteneur, leur visibilité change et Intersection Observer déclenche un rappel.
Nous avons besoin de deux sentinelles pour couvrir quatre cas de défilement vers le haut et vers le bas:
- Défilement vers le bas : l'en-tête devient collant lorsque sa sentinelle supérieure croise le haut du conteneur.
- Défilement vers le bas : l'en-tête quitte le mode persistant lorsqu'il atteint le bas de la section et que sa sentinelle inférieure croise le haut du conteneur.
- Défilement vers le haut : l'en-tête quitte le mode persistant lorsque sa sentinelle supérieure revient à l'écran depuis le haut.
- Défilement vers le haut : l'en-tête devient collant lorsque sa sentinelle inférieure revient en vue depuis le haut.
Il est utile de regarder une vidéo de ces étapes 1 à 4 dans l'ordre où elles se produisent:
Le CSS
Les sentinelles sont placé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:
: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 Intersection Observers
Les Intersection Observers observent de manière asynchrone les modifications de l'intersection d'un élément cible et de la fenêtre du document ou d'un conteneur parent. Dans notre cas, nous observons des intersections avec un conteneur parent.
La sauce magique est IntersectionObserver
. Chaque sentinelle reçoit un IntersectionObserver
pour observer la visibilité de son intersection dans le conteneur de défilement. Lorsqu'une sentinelle fait défiler la fenêtre d'affichage visible, nous savons qu'un en-tête est devenu fixe 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 sentinelles 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 le haut du conteneur à faire défiler (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 si elle entre ou quitte le viewport. 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--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 en bas du conteneur à défilement. La fonction observeFooters
crée les nœuds sentinelles et les associe à chaque section. L'observateur calcule l'intersection de la sentinelle avec le bas du conteneur et décide si elle entre ou sort. Ces informations déterminent si l'en-tête de section est fixe 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 l'ensemble du nœud est visible.
Enfin, voici mes deux utilitaires pour déclencher l'événement personnalisé sticky-change
et 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émo finale
Nous avons créé un événement personnalisé lorsque les éléments avec position:sticky
deviennent fixes et ajouté des effets de défilement sans utiliser d'événements scroll
.
Conclusion
Je me suis souvent demandé si IntersectionObserver
serait un outil utile pour remplacer certains des modèles d'UI basés sur les événements scroll
développés au fil des ans. La réponse est oui et non. La sémantique de l'API IntersectionObserver
la rend difficile à utiliser pour tout. Mais comme je l'ai montré ici, vous pouvez l'utiliser pour certaines techniques intéressantes.
Une autre façon de détecter les modifications de style ?
Pas vraiment. Nous avions besoin d'un moyen d'observer les modifications de style sur un élément DOM. Malheureusement, aucune des API de la plate-forme Web ne vous permet de surveiller les modifications de style.
Un MutationObserver
serait un premier choix logique, mais cela ne fonctionne pas dans la plupart des cas. Par exemple, dans la 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 modification de style) aux Observateurs de modification peut être utile pour observer les modifications apportées aux styles calculés d'un élément.
position: sticky
.