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

Texto longo, leia o resumo

Confira uma chave secreta: talvez os eventos scroll não sejam necessários no seu próximo app. Usar um IntersectionObserver, Mostramos como disparar um evento personalizado quando os elementos do position:sticky são fixos ou param de serem fixados. Tudo sem o uso de listeners de rolagem. Existe até uma demonstração incrível para comprovar isso:

Ver demonstração | Fonte

Apresentamos o evento sticky-change

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

Confira o exemplo a seguir, que corrige uma <div class="sticky"> de 10px da do contêiner pai:

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

Não seria bom se o navegador informasse quando os elementos alcançassem essa marca? Aparentemente não sou a única que pensa assim. Um indicador para position:sticky pode desbloquear vários casos de uso:

  1. Aplique uma sombra projetada a um banner enquanto ele é fixo.
  2. Conforme um usuário lê seu conteúdo, registre hits de análise para saber o progresso.
  3. À medida que o usuário rola a página, atualize um widget flutuante de TOC para a configuração atual. nesta seção.

Com esses casos de uso em mente, definimos uma meta final: criar um evento que é disparado quando um elemento position:sticky se torna fixo. Vamos chamá-lo 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 aos cabeçalhos, uma sombra projetada quando eles são corrigidos. Ele também atualiza novo título na parte superior da página.

Na demonstração, os efeitos são aplicados sem eventos de rolagem.

Efeitos de rolagem sem eventos de rolagem?

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

Vamos acabar com a terminologia para poder nos referir a esses nomes no restante da postagem:

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

Para saber qual cabeçalho entra no "modo tecla fixa", precisamos de uma forma de determinar o deslocamento de rolagem do contêiner de rolagem. Isso nos daria uma maneira para calcular o cabeçalho que está sendo exibido. No entanto, isso é bastante é difícil fazer sem eventos scroll :) O outro problema é que position:sticky remove o elemento do layout quando ele se torna fixo.

Assim, sem os eventos de rolagem, perdemos a capacidade de realizar alterações cálculos nos cabeçalhos.

Adicionar um DOM fictício para determinar a posição de rolagem.

Em vez de eventos scroll, vamos usar um IntersectionObserver para determine quando os cabeçalhos entram e saem do modo tecla fixa. Como adicionar dois nós (também chamadas de sentinelas) em cada seção adesiva, uma na parte de cima e outra na parte inferior, servirão como waypoints para descobrir a posição de rolagem. Como esses quando os marcadores entram e saem do contêiner, sua visibilidade muda e O Intersection Observer aciona um callback.

Sem elementos de sentinela mostrados
Os elementos de sentinela ocultos.

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

  1. Rolagem para baixo: o cabeçalho se torna fixo quando a sentinela superior cruza na parte superior do contêiner.
  2. Rolagem para baixo: o cabeçalho deixa o modo fixo quando chega à parte de baixo do e a sentinela inferior cruza a parte superior do contêiner.
  3. Rolagem para cima: o header deixa o modo fixo quando a sentinela superior rola a tela. de volta à vista de cima.
  4. Rolando para cima: o cabeçalho se torna fixo quando a sentinela inferior cruza para trás. vista de cima.

É útil ver um screencast de um a quatro grupos na ordem em que eles acontecem:

Os observadores de interseção disparam callbacks quando as sentinelas entrar/sair do contêiner de rolagem.

O CSS

As sentinelas são posicionadas na parte de cima e de baixo de cada seção. A .sticky_sentinel--top fica na parte de cima do cabeçalho, .sticky_sentinel--bottom fica na parte inferior da seção:

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

Configurar os observadores de interseção

Observadores de interseção observam alterações de forma assíncrona na interseção de um elemento de destino e a janela de visualização do documento ou um contêiner principal. No nosso caso, estamos observando interseções com um contêiner pai.

O molho mágico é IntersectionObserver. Cada sentinela recebe uma IntersectionObserver para observar a visibilidade da interseção dentro da contêiner de rolagem. Quando uma sentinela rola para a janela de visualização visível, sabemos um cabeçalho se tornou fixo ou parou de ser fixo. Da mesma forma, quando uma sentinela sai na 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 passarem pela parte de cima do contêiner de rolagem (em qualquer direção). A função observeHeaders cria as principais sentinelas e as adiciona a cada seção. O observador calcula a interseção da sentinela com topo do contêiner e decide se ele entra ou sai da janela de visualização. Isso determina se o cabeçalho da seção é 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 conforme a sentinela se torna visível.

O processo é semelhante para a sentinela inferior (.sticky_sentinel--bottom). Um segundo observador é criado para ser acionado quando os rodapés passam pela parte inferior do contêiner de rolagem. A função observeFooters cria o nós sentinelas e os anexa a cada seção. O observador calcula a interseção da sentinela com o fundo do contêiner e decide se é entrar ou sair. Essa informação determina se o cabeçalho da seção aderir 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 o o nó inteiro está visível.

Por fim, há meus dois utilitários para disparar 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 se tornam foi corrigido e adicionado efeitos de rolagem sem o uso de eventos scroll

Ver demonstração | Fonte

Conclusão

Eu me pergunto muitas vezes se IntersectionObserver pode ser uma ferramenta útil para substituir alguns dos padrões de interface do scroll baseados em eventos que evoluíram ao longo dos anos. Acontece que a resposta é sim e não. A semântica da API IntersectionObserver dificultam o uso para tudo. Mas, conforme que eu mostrei aqui, é possível usá-la para algumas técnicas interessantes.

Outra maneira de detectar mudanças de estilo?

Na verdade, não. Precisávamos de uma forma de observar as mudanças de estilo de um elemento DOM. Infelizmente, não há nada nas APIs da plataforma da Web que permita mudanças no estilo do relógio.

Uma MutationObserver seria uma primeira escolha lógica, mas isso não funciona para na maioria dos casos. Por exemplo, na demonstração, receberíamos um callback quando o método sticky é adicionada a um elemento, mas não quando o estilo computado do elemento muda. Lembre-se de que a classe sticky já foi declarada no carregamento de página.

No futuro, "Observador de mutação de estilo" para Mutation Observers pode ser útil para observar as mudanças em uma estilos computados do elemento. position: sticky.