Texto longo, leia o resumo
Um segredo: talvez os eventos scroll
não sejam necessários no seu próximo app. Usando um
IntersectionObserver
,
mostramos como disparar um evento personalizado quando os elementos position:sticky
são corrigidos ou param de serem mantidos. Tudo sem o uso de listeners de rolagem. Existe até uma demonstração incrível para comprovar isso:
Apresentamos o evento sticky-change
Uma das limitações práticas de usar a 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á evento para saber quando um elemento se torna fixo ou quando ele para de ser aderente.
Confira o exemplo abaixo, que corrige uma <div class="sticky">
de 10px da
parte de cima do contêiner pai:
.sticky {
position: sticky;
top: 10px;
}
Não seria bom se o navegador informasse quando os elementos alcançassem essa marca?
Parece que não sou a única pessoa
que pensa assim. Um indicador para position:sticky
pode desbloquear vários casos de uso:
- Aplique uma sombra projetada a um banner enquanto ele é fixo.
- Enquanto um usuário lê seu conteúdo, registre hits de análise para saber o progresso.
- À medida que o usuário rola a página, atualize um widget flutuante de TOC para a seção atual.
Com esses casos de uso em mente, criamos uma meta final: 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 para cabeçalhos de sombra projetada quando eles são corrigidos. Ele também atualiza o novo título na parte superior da página.
Efeitos de rolagem sem eventos de rolagem?
![Estrutura da página.](https://developer.chrome.com/static/docs/css-ui/sticky-headers/image/structure-the-page-de19d5c111bd1.png?authuser=6&hl=pt)
Vamos acabar com a terminologia para poder nos referir a esses nomes ao longo do restante da postagem:
- Contêiner de rolagem: a área de conteúdo (janela de visualização visível) que contém a lista de "postagens do blog".
- Cabeçalhos: título azul em cada seção com
position:sticky
. - Seções fixas: cada seção de conteúdo. O texto que rola abaixo dos cabeçalhos fixos.
- "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 permite calcular o cabeçalho que está sendo mostrado. No entanto, isso é muito
complicado sem os eventos scroll
. O outro problema é que
position:sticky
remove o elemento do layout quando ele é corrigido.
Portanto, sem os eventos de rolagem, perdemos a capacidade de realizar cálculos relacionados ao layout 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
determinar quando os headers 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 waypoints 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 aciona um callback.
![Sem elementos de sentinela mostrados](https://developer.chrome.com/static/docs/css-ui/sticky-headers/image/without-sentinel-elements-f9529d3483c91.gif?authuser=6&hl=pt)
Precisamos de duas sentinelas para cobrir quatro casos de rolagem para cima e para baixo:
- Rolagem para baixo: o cabeçalho se torna fixo quando a sentinela de cima cruza a parte de cima do contêiner.
- Rolar para baixo: o header deixa o modo fixo ao alcançar a parte de baixo da seção, e a sentinela inferior cruza a parte de cima do contêiner.
- Rolagem para cima: o cabeçalho deixa o modo fixo quando a sentinela de cima rola de volta para a tela de cima.
- Rolagem para cima: o cabeçalho se torna fixo quando a sentinela inferior volta à visualização de cima para baixo.
É útil ver um screencast de um a quatro grupos na ordem em que eles acontecem:
O CSS
As sentinelas são posicionadas 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.](https://developer.chrome.com/static/docs/css-ui/sticky-headers/image/bottom-sentinel-reaching-208a13f07bd7c.png?authuser=6&hl=pt)
: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 intersecções observam de forma assíncrona alterações na interseção de um elemento de destino e a janela de visualização do documento ou um contêiner pai. No nosso caso, estamos observando 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 ficou fixo ou parou 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 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
a parte superior do contêiner e decide se ela entra ou sai da janela de visualização. 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--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 disparado assim
que a sentinela ficar visível.
O processo é semelhante para a sentinela inferior (.sticky_sentinel--bottom
).
Um segundo observador é criado para ser disparado quando os rodapés passam pela parte inferior
do contêiner de rolagem. A função observeFooters
cria os
nós de sentimento e os anexa a cada seção. O observador calcula a
interseção da sentinela com a parte inferior do contêiner e decide se ela está
entrando ou saindo. Essa informação 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--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 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
são
fixos e adicionamos efeitos de rolagem sem o uso de eventos scroll
.
Conclusão
Muitas vezes me pergunto se o IntersectionObserver
seria
uma ferramenta útil para substituir alguns dos padrões de interface do scroll
com base em eventos
desenvolvidos ao longo dos anos. Acontece que a resposta é sim e não. A semântica
da API IntersectionObserver
dificulta o uso para tudo. Mas, como mostrei aqui,
é possível usá-lo 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 Web que permita que você assista às 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, receberemos um callback quando a classe sticky
for adicionada a um elemento, mas não quando o estilo computado dele mudar.
Lembre-se de que a classe sticky
já foi declarada no carregamento de página.
No futuro, uma extensão
Style Mutation Observer
para o Mutation Observers poderá ser útil para observar as mudanças nos
estilos computados de um elemento.
position: sticky
.