Un evento para el elemento position:sticky de CSS

Resumen

Te dejo un secreto: Es posible que no necesites eventos scroll en tu próxima app. Con un IntersectionObserver, muestro cómo puedes activar un evento personalizado cuando los elementos position:sticky se fijan o cuando dejan de permanecer. Todo sin el uso de objetos de escucha de desplazamiento. Incluso hay una demostración increíble que lo demuestra:

Ver demostración | Fuente

Presentamos el evento sticky-change

Una de las limitaciones prácticas de usar la posición persistente de CSS es que no proporciona un indicador de plataforma para saber cuándo la propiedad está activa. En otras palabras, no hay evento para saber cuándo un elemento se vuelve fijo o cuándo deja de hacerlo.

Observa el siguiente ejemplo, en el que se corrige un <div class="sticky"> de 10 px de la parte superior de su contenedor superior:

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

¿No sería fantástico que el navegador lo dijera cuando los elementos lleguen a esa marca? Aparentemente, no soy la única persona que lo cree. Un indicador de position:sticky podría desbloquear varios casos de uso:

  1. Aplica una sombra paralela a un banner mientras se fija.
  2. A medida que un usuario lea tu contenido, registra los hits de estadísticas para conocer su progreso.
  3. A medida que el usuario se desplaza por la página, actualiza un widget de TOC flotante en la sección actual.

Con estos casos de uso en mente, creamos un objetivo final: crear un evento que se active cuando se corrija un elemento position:sticky. La llamaremos el evento 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 demostración usa este evento para encabezar una sombra paralela cuando se corrigen. También actualiza el título nuevo en la parte superior de la página.

En la demostración, los efectos se aplican sin Scrollevents.

¿Efectos de desplazamiento sin eventos de desplazamiento?

Estructura de la página
Estructura de la página.

Ahora, veamos un poco de terminología para poder consultar estos nombres en el resto de la publicación:

  1. Contenedor de desplazamiento: El área de contenido (viewport visible) que contiene la lista de "entradas de blog".
  2. Encabezados: Es un título azul en cada sección que contiene position:sticky.
  3. Secciones especiales: Cada sección de contenido. El texto que se desplaza debajo de los encabezados fijos.
  4. "Modo permanente": Cuando position:sticky se aplica al elemento.

Para saber qué header ingresa en el "modo permanente", necesitamos alguna forma de determinar el desplazamiento del contenedor de desplazamiento. Eso nos daría una manera de calcular el encabezado que se muestra actualmente. Sin embargo, resulta bastante complicado hacerlo sin eventos scroll. El otro problema es que position:sticky quita el elemento del diseño cuando se corrige.

Por lo tanto, sin eventos de desplazamiento, perdimos la capacidad de realizar cálculos relacionados con el diseño en los encabezados.

Agrega un DOM dumby para determinar la posición de desplazamiento

En lugar de eventos scroll, usaremos un IntersectionObserver para determinar cuándo los headers entran y salen del modo permanente. Agregar dos nodos (centinelas) en cada sección adhesiva, uno en la parte superior y otro en la parte inferior, actuarán como puntos de referencia para determinar la posición de desplazamiento. Cuando estos marcadores ingresan y salen del contenedor, cambia su visibilidad e Intersection Observer activa una devolución de llamada.

Sin elementos centinela que se muestren
Los elementos centinela ocultos.

Necesitamos dos centinelas para cubrir cuatro casos de desplazamiento hacia arriba y hacia abajo:

  1. Desplazamiento hacia abajo: el encabezado se vuelve fijo cuando su centinela superior cruza la parte superior del contenedor.
  2. Desplazamiento hacia abajo: El encabezado sale del modo permanente cuando llega a la parte inferior de la sección y su centinela inferior cruza la parte superior del contenedor.
  3. Desplazamiento hacia arriba: El encabezado deja el modo permanente cuando el centinela superior se desplaza nuevamente a la vista desde la parte superior.
  4. Desplazamiento hacia arriba: El encabezado se vuelve fijo cuando el centinela inferior se cruza de nuevo a la vista desde la parte superior.

Es útil ver una presentación en pantalla de 1 a 4 en el orden en que ocurren:

Los observadores de interconexión activan devoluciones de llamada cuando los centinelas entran o salen del contenedor de desplazamiento.

El CSS

Los centinelas se posicionan en la parte superior e inferior de cada sección. .sticky_sentinel--top se ubica en la parte superior del encabezado, mientras que .sticky_sentinel--bottom se ubica en la parte inferior de la sección:

Centinela inferior que alcanza su umbral.
Posición de los elementos centinela superior e inferior.
: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;
}

Cómo configurar los observadores de intersección

Los observadores de intersección observan de forma asíncrona los cambios en la intersección de un elemento de destino y el viewport del documento o un contenedor superior. En nuestro caso, observamos intersecciones con un contenedor superior.

La salsa mágica es IntersectionObserver. Cada centinela obtiene un IntersectionObserver para observar su visibilidad de intersección dentro del contenedor de desplazamiento. Cuando un centinela se desplaza por el viewport visible, sabemos que un encabezado se fija o deja de ser fijo. Del mismo modo, cuando un centinela sale del viewport.

Primero, configuré observadores para los centinelas del encabezado y el pie de página:

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

Luego, agregué un observador para que se active cuando los elementos .sticky_sentinel--top pasen por la parte superior del contenedor de desplazamiento (en cualquier dirección). La función observeHeaders crea los centinelas superiores y los agrega a cada sección. El observador calcula la intersección del centinela con la parte superior del contenedor y decide si entra o sale del viewport. Esa información determina si el encabezado de la sección se pega o no.

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

El observador se configura con threshold: [0] para que su devolución de llamada se active en cuanto el sentinel se vuelva visible.

El proceso es similar para el centinela inferior (.sticky_sentinel--bottom). Se crea un segundo observador para que se active cuando los pies de página pasen por la parte inferior del contenedor de desplazamiento. La función observeFooters crea los nodos centinela y los adjunta a cada sección. El observador calcula la intersección del centinela con la parte inferior del contenedor y decide si entra o sale. Esa información determina si el encabezado de la sección se pega o no.

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

El observador está configurado con threshold: [1] por lo que se activa la devolución de llamada cuando todo el nodo está dentro de la vista.

Por último, existen dos utilidades para activar el evento personalizado sticky-change y generar los centinelas:

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

Listo.

Demostración final

Creamos un evento personalizado cuando los elementos con position:sticky se vuelven fijos y agregamos efectos de desplazamiento sin usar eventos scroll.

Ver demostración | Fuente

Conclusión

A menudo, me pregunto si IntersectionObserver sería una herramienta útil para reemplazar algunos de los patrones de la IU basados en eventos scroll que se desarrollaron a lo largo de los años. Resulta que la respuesta es sí y no. La semántica de la API de IntersectionObserver dificulta su uso para todo. Sin embargo, como he mostrado aquí, puedes usarlo para algunas técnicas interesantes.

¿Otra forma de detectar cambios de diseño?

En realidad, no. Lo que necesitábamos era una forma de observar los cambios de estilo en un elemento del DOM. Lamentablemente, las APIs de la plataforma web no incluyen nada que te permita mirar los cambios de diseño.

Un MutationObserver sería una primera opción lógica, pero eso no funciona en la mayoría de los casos. Por ejemplo, en la demostración, recibiremos una devolución de llamada cuando la clase sticky se agregue a un elemento, pero no cuando cambie el estilo calculado del elemento. Recuerda que la clase sticky ya se declaró cuando se carga la página.

En el futuro, la extensión "Style Mutation Observer" para Mutation Observers podría ser útil para observar los cambios en los diseños calculados de un elemento. position: sticky.