Un evento per il CSSposition:sticky

TL;DR

Ecco un segreto: potresti non aver bisogno di eventi scroll nella tua prossima app. Con un IntersectionObserver, ti mostro come puoi attivare un evento personalizzato quando gli elementi position:sticky diventano fissi o quando smettono di esserlo. Il tutto senza dover utilizzare gli ascoltatori dello scorrimento. Ecco una fantastica demo che lo dimostra:

Visualizza la demo | Origine

Introduzione all'evento sticky-change

Uno dei limiti pratici dell'utilizzo della posizione fissa CSS è che non fornisce un indicatore della piattaforma per sapere quando la proprietà è attiva. In altre parole, non esiste un evento che ti consenta di sapere quando un elemento diventa fisso o quando smette di esserlo.

Prendi l'esempio seguente, che fissa un <div class="sticky"> a 10px dalla parte superiore del contenitore principale:

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

Non sarebbe bello se il browser indicasse quando gli elementi raggiungono questo limite? A quanto pare, non sono l'unico a pensarlo. Un indicatore per position:sticky potrebbe sbloccare una serie di casi d'uso:

  1. Applica un'ombra a un banner quando si attacca.
  2. Mentre un utente legge i tuoi contenuti, registra gli hit di analisi per conoscere il suo andamento.
  3. Mentre l'utente scorre la pagina, aggiorna un widget TOC mobile con la sezione corrente.

Tenendo a mente questi casi d'uso, abbiamo creato un obiettivo finale: creare un evento che si attiva quando un elemento position:sticky diventa fisso. Chiamiamolo eventosticky-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 demo utilizza questo evento per applicare un'ombra alle intestazioni quando diventano fisse. Viene aggiornato anche il nuovo titolo nella parte superiore della pagina.

Nella demo, gli effetti vengono applicati senza scrollevents.

Effetti di scorrimento senza eventi di scorrimento?

Struttura della pagina.
Struttura della pagina
.

Chiariamo subito alcuni termini per poter fare riferimento a questi nomi nel resto del post:

  1. Contenitore scorrevole: l'area dei contenuti (area visibile del viewport) contenente l'elenco dei "post del blog".
  2. Intestazioni: titolo blu in ogni sezione contenente position:sticky.
  3. Sezioni fisse: ogni sezione di contenuti. Il testo che scorre sotto le intestazioni fisse.
  4. "Modalità persistente": quando position:sticky viene applicato all'elemento.

Per sapere quale intestazione entra in "modalità fissa", abbiamo bisogno di un modo per determinare l'offset dello scorrimento del contenitore scorrevole. In questo modo potremmo calcolare l'intestazione attualmente visualizzata. Tuttavia, è piuttosto difficile da fare senza eventi scroll. L'altro problema è che position:sticky rimuove l'elemento dal layout quando diventa fisso.

Pertanto, senza eventi di scorrimento, abbiamo perso la possibilità di eseguire calcoli relativi al layout sulle intestazioni.

Aggiunta di un DOM dummy per determinare la posizione di scorrimento

Anziché gli eventi scroll, utilizzeremo un IntersectionObserver per determinare quando le intestazioni entrano ed escono dalla modalità fissa. L'aggiunta di due nodi (noti anche come sentinelle) in ogni sezione fissa, uno in alto e uno in basso, fungerà da waypoint per determinare la posizione di scorrimento. Quando questi indicatori entrano e escono dal contenitore, la loro visibilità cambia e Intersection Observer attiva un callback.

Senza elementi sentinella visibili
Gli elementi sentinella nascosti.

Abbiamo bisogno di due sentinelle per coprire quattro casi di scorrimento verso l'alto e verso il basso:

  1. Scorrimento verso il basso: l'intestazione diventa fissa quando la sentinella superiore supera la parte superiore del contenitore.
  2. Scorrimento verso il basso: l'intestazione esce dalla modalità fissa quando raggiunge la parte inferiore della sezione e il suo indicatore di guardia inferiore attraversa la parte superiore del contenitore.
  3. Scorrimento verso l'alto: l'intestazione esce dalla modalità fissa quando la sentinella superiore viene visualizzata nuovamente dall'alto.
  4. Scorrimento verso l'alto: l'intestazione diventa fissa quando l'indicatore di guardia inferiore rientra nuovamente in vista dall'alto.

È utile guardare uno screencast dei passaggi da 1 a 4 nell'ordine in cui si verificano:

Gli osservatori di intersezione attivano i richiami quando le sentinelle entrano/escono dal contenitore di scorrimento.

Il CSS

Le sentinelle sono posizionate nella parte superiore e inferiore di ogni sezione. .sticky_sentinel--top si trova nella parte superiore dell'intestazione, mentre .sticky_sentinel--bottom si trova nella parte inferiore della sezione:

La sentinella di fondo raggiunge la soglia.
Posizione degli elementi sentinella superiore e inferiore.
: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;
}

Configurazione degli osservatori degli incroci

Gli osservatori di intersezione rilevano in modo asincrono le modifiche nell'intersezione tra un elemento target e l'area visibile del documento o un contenitore principale. Nel nostro caso, osserviamo le intersezioni con un contenitore principale.

La salsa magica è IntersectionObserver. A ogni sentinella viene assegnato un IntersectionObserver per osservare la visibilità dell'intersezione all'interno del contenitore di scorrimento. Quando un indicatore viene visualizzato nell'area visibile del viewport, sappiamo che un'intestazione è diventata fissa o ha smesso di esserlo. Analogamente, quando un sentinella esce dall'area visibile.

Innanzitutto, ho configurato gli osservatori per gli indicatori di intestazione e piè di pagina:

/**
 * 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'));

Poi ho aggiunto un osservatore da attivare quando gli elementi .sticky_sentinel--top passano tra la parte superiore del contenitore scorrevole (in entrambe le direzioni). La funzione observeHeaders crea le sentinelle superiori e le aggiunge a ogni sezione. L'osservatore calcola l'intersezione della sentinella con la parte superiore del contenitore e decide se sta entrando o uscendo dal viewport. Queste informazioni determinano se l'intestazione della sezione è fissa o meno.

/**
 * 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'osservatore è configurato con threshold: [0], quindi il suo callback viene attivato non appena la sentinella diventa visibile.

La procedura è simile per l'indicatore di fondo (.sticky_sentinel--bottom). Viene creato un secondo osservatore da attivare quando i piè di pagina passano nella parte inferiore del contenitore con scorrimento. La funzione observeFooters crea i nodi sentinella e li collega a ogni sezione. L'osservatore calcola l'intersezione della sentinella con il fondo del contenitore e decide se è in entrata o in uscita. Queste informazioni determinano se l'intestazione della sezione è bloccata o meno.

/**
 * 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'osservatore è configurato con threshold: [1], quindi il suo callback viene attivato quando l'intero nodo è visibile.

Infine, ecco le mie due utilità per attivare l'evento personalizzato sticky-change e generare le sentinelle:

/**
 * @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);
}

È tutto.

Demo finale

Abbiamo creato un evento personalizzato quando gli elementi con position:sticky diventano bloccati e abbiamo aggiunto effetti di scorrimento senza utilizzare gli eventi scroll.

Visualizza la demo | Origine

Conclusione

Mi sono spesso chiesto se IntersectionObserver potesse essere uno strumento utile per sostituire alcuni dei scrollpattern di UI basati su eventi che si sono sviluppati nel corso degli anni. La risposta è sì e no. La semantica dell'API IntersectionObserver ne rende difficile l'utilizzo per tutto. Tuttavia, come ho mostrato qui, puoi utilizzarlo per alcune tecniche interessanti.

Esiste un altro modo per rilevare le modifiche dello stile?

Non esattamente. Ci serviva un modo per osservare le modifiche dello stile in un elemento DOM. Purtroppo, le API della piattaforma web non ti consentono di monitorare le modifiche dello stile.

Un MutationObserver sarebbe una prima scelta logica, ma non funziona per la maggior parte dei casi. Ad esempio, nella demo riceveremmo un callback quando la classe sticky viene aggiunta a un elemento, ma non quando lo stile calcolato dell'elemento cambia. Ricorda che la classe sticky è già stata dichiarata al caricamento della pagina.

In futuro, un'estensione"Style Mutation Observer" ai Mutation Observer potrebbe essere utile per osservare le modifiche agli stili calcolati di un elemento. position: sticky.