Um evento para "Posição:fixa do CSS"

Texto longo, leia o resumo

Aqui está um segredo: talvez você não precise de eventos scroll no seu próximo app. Usando um IntersectionObserver, mostro como acionar um evento personalizado quando os elementos position:sticky são fixados ou quando param de ser fixados. Tudo isso sem o uso de listeners de rolagem. Há até uma demonstração incrível para provar isso:

Conferir a demonstração | Origem

Apresentação do evento sticky-change

Uma das limitações práticas do uso da posição fixa do CSS é que ela não fornece um indicador de plataforma para saber quando a propriedade está ativa. Em outras palavras, não há um evento para saber quando um elemento se torna fixo ou quando ele deixa de ser fixo.

Considere o exemplo a seguir, que fixa um <div class="sticky"> a 10 px da parte de cima do contêiner pai:

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

Não seria ótimo se o navegador informasse quando os elementos atingem essa marca? Aparentemente, não sou o único que pensa assim. Um indicador para position:sticky pode desbloquear vários casos de uso:

  1. Aplicar uma sombra projetada a um banner enquanto ele é fixado.
  2. À medida que um usuário lê seu conteúdo, registre as impressões do Google Analytics para saber o progresso dele.
  3. Conforme o usuário rola a página, atualize um widget de TOC flutuante para a seção atual.

Com esses casos de uso em mente, criamos um objetivo final: criar um evento que é acionado quando um elemento position:sticky se torna fixo. Vamos chamar de 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;
});

A demonstração usa esse evento para cabeçalhos de uma sombra projetada quando eles são fixados. Ele também atualiza o novo título na parte de cima da página.

Na demonstração, os efeitos são aplicados sem scrollevents.

Efeitos de rolagem sem eventos de rolagem?

Estrutura da página.
Estrutura da página.

Vamos esclarecer alguns termos para que eu possa me referir a esses nomes ao longo do resto da postagem:

  1. Contêiner de rolagem: a área de conteúdo (janela de visualização visível) que contém a lista de "postagens do blog".
  2. Cabeçalhos: título azul em cada seção que tem position:sticky.
  3. Seções fixas: cada seção de conteúdo. O texto que rola abaixo dos cabeçalhos fixos.
  4. "Modo aderente": quando position:sticky é aplicado ao elemento.

Para saber qual cabeçalho entra no "modo fixo", precisamos de uma maneira de determinar o deslocamento de rolagem do contêiner de rolagem. Isso nos daria uma maneira de calcular o cabeçalho que está sendo mostrado. No entanto, isso fica bastante complicado sem eventos scroll :) O outro problema é que position:sticky remove o elemento do layout quando ele é fixado.

Sem os eventos de rolagem, perdemos a capacidade de realizar cálculos relacionados ao layout nos cabeçalhos.

Adicionar DOM fictício para determinar a posição do rolagem

Em vez de eventos scroll, vamos usar um IntersectionObserver para determinar quando os cabeçalhos entram e saem do modo fixo. Adicionar dois nós (também conhecidos como sentinelas) em cada seção fixa, um na parte de cima e outro na parte de baixo, vai funcionar como pontos de passagem para descobrir a posição de rolagem. À medida que esses marcadores entram e saem do contêiner, a visibilidade deles muda e o Intersection Observer dispara um callback.

Sem elementos sentinela mostrados
Os elementos sentinela ocultos.

Precisamos de duas sentinelas para cobrir quatro casos de rolagem para cima e para baixo:

  1. Rolar para baixo: o cabeçalho fica fixo quando a sentinela superior cruza a parte de cima do contêiner.
  2. Rolar para baixo: o cabeçalho sai do modo fixo quando chega ao final da seção, e a sentinela inferior cruza a parte de cima do contêiner.
  3. Rolar para cima: o cabeçalho sai do modo fixo quando a sentinela de cima rola de volta para a visualização.
  4. Rolar para cima: o cabeçalho fica fixo quando a sentinela de baixo cruza de volta para a visualização de cima.

É útil conferir um screencast das etapas 1 a 4 na ordem em que elas ocorrem:

Os observadores de interseção acionam callbacks quando as sentinelas entram/saem do contêiner de rolagem.

O CSS

Os sentinelas ficam na parte de cima e de baixo de cada seção. .sticky_sentinel--top fica na parte de cima do cabeçalho, enquanto .sticky_sentinel--bottom fica na parte de baixo da seção:

Sentinela inferior atingindo o limite.
Posição dos elementos sentinela de cima e de baixo.
: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;
}

Como configurar os observadores de interseção

Os observadores de interseção observam de forma assíncrona as mudanças na interseção de um elemento de destino e a viewport do documento ou um contêiner pai. No nosso caso, observamos interseções com um contêiner pai.

O molho mágico é IntersectionObserver. Cada sentinela recebe um IntersectionObserver para observar a visibilidade da interseção no contêiner de rolagem. Quando uma sentinela rola para a janela de visualização visível, sabemos que um cabeçalho se tornou fixo ou deixou de ser fixo. Da mesma forma, quando uma sentinela sai da janela de visualização.

Primeiro, configurei observadores para as sentinelas de cabeçalho e rodapé:

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

Em seguida, adicionei um observador para disparar quando os elementos .sticky_sentinel--top passassem pela parte de cima do contêiner de rolagem (em qualquer direção). A função observeHeaders cria as sentinelas principais e as adiciona a cada seção. O observador calcula a interseção da sentinela com a parte de cima do contêiner e decide se ela está entrando ou saindo da viewport. Essas informações determinam se o cabeçalho da seção está fixo ou não.

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

O observador é configurado com threshold: [0] para que o callback seja acionado assim que a sentinela ficar visível.

O processo é semelhante para a sentinela inferior (.sticky_sentinel--bottom). Um segundo observador é criado para disparar quando os rodapés passam pela parte de baixo do contêiner de rolagem. A função observeFooters cria os nós sentinela e os anexa a cada seção. O observador calcula a interseção da sentinela com a parte de baixo do contêiner e decide se ela está entrando ou saindo. Essas informações determinam se o cabeçalho da seção está fixado ou não.

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

O observador é configurado com threshold: [1] para que o callback seja acionado quando todo o nó estiver visível.

Por fim, há meus dois utilitários para acionar o evento personalizado sticky-change e gerar as sentinelas:

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

Pronto!

Demonstração final

Criamos um evento personalizado quando os elementos com position:sticky são fixados e adicionamos efeitos de rolagem sem o uso de eventos scroll.

Conferir a demonstração | Origem

Conclusão

Muitas vezes, me pergunto se o IntersectionObserver seria uma ferramenta útil para substituir alguns dos padrões de IU baseados em eventos scroll que foram desenvolvidos ao longo dos anos. A resposta é sim e não. A semântica da API IntersectionObserver dificulta o uso para tudo. Mas, como mostrei aqui, você pode usá-lo para algumas técnicas interessantes.

Outra maneira de detectar mudanças de estilo?

Na verdade, não. O que precisávamos era uma maneira de observar mudanças de estilo em um elemento DOM. Infelizmente, não há nada nas APIs da plataforma da Web que permita monitorar mudanças de estilo.

Um MutationObserver seria uma primeira escolha lógica, mas isso não funciona na maioria dos casos. Por exemplo, na demonstração, receberíamos um callback quando a classe sticky fosse adicionada a um elemento, mas não quando o estilo computado do elemento mudasse. A classe sticky já foi declarada no carregamento da página.

No futuro, uma extensão Style Mutation Observer para observadores de mutação pode ser útil para observar mudanças nos estilos computados de um elemento. position: sticky.