Construindo um cabeçalho dinâmico com observador de interseção
Publicados: 2022-03-10A API Intersection Observer é uma API JavaScript que nos permite observar um elemento e detectar quando ele passa por um ponto especificado em um contêiner de rolagem - geralmente (mas nem sempre) a janela de visualização - acionando uma função de retorno de chamada.
O Observador de Interseção pode ser considerado mais eficiente do que ouvir eventos de rolagem no encadeamento principal, pois é assíncrono, e o retorno de chamada só será acionado quando o elemento que estivermos observando atingir o limite especificado, em vez disso, toda vez que a posição de rolagem for atualizada. Neste artigo, veremos um exemplo de como podemos usar o Intersection Observer para criar um componente de cabeçalho fixo que muda quando cruza com diferentes seções da página da web.
Uso básico
Para usar o Observador de Interseção, precisamos primeiro criar um novo observador, que recebe dois parâmetros: um objeto com as opções do observador e a função de retorno de chamada que queremos executar sempre que o elemento que estamos observando (conhecido como destino do observador) cruzar com a raiz (o contêiner de rolagem, que deve ser um ancestral do elemento de destino).
const options = { root: document.querySelector('[data-scroll-root]'), rootMargin: '0px', threshold: 1.0 } const callback = (entries, observer) => { entries.forEach((entry) => console.log(entry)) } const observer = new IntersectionObserver(callback, options)
Quando criamos nosso observador, precisamos instruí-lo a observar um elemento de destino:
const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)
Qualquer um dos valores de opções pode ser omitido, pois eles retornarão aos seus valores padrão:
const options = { rootMargin: '0px', threshold: 1.0 }
Se nenhuma raiz for especificada, ela será classificada como a janela de visualização do navegador. O exemplo de código acima mostra os valores padrão para rootMargin
e threshold
. Estes podem ser difíceis de visualizar, então vale a pena explicar:
rootMargin
O valor rootMargin
é um pouco como adicionar margens CSS ao elemento raiz — e, assim como as margens, pode receber vários valores, incluindo valores negativos. O elemento de destino será considerado como tendo interseção em relação às margens.
Isso significa que um elemento pode tecnicamente ser classificado como “interseção” mesmo quando estiver fora de vista (se nossa raiz de rolagem for a janela de visualização).
O padrão rootMargin
é 0px
, mas pode receber uma string que consiste em vários valores, assim como usar a propriedade margin
em CSS.
threshold
O threshold
pode consistir em um único valor ou uma matriz de valores entre 0 e 1. Ele representa a proporção do elemento que deve estar dentro dos limites da raiz para que seja considerado interseção . Usando o valor padrão de 1, o retorno de chamada será acionado quando 100% do elemento de destino estiver visível na raiz.
Nem sempre é fácil visualizar quando um elemento será classificado como visível usando essas opções. Eu construí uma pequena ferramenta para ajudar a entender o Intersection Observer.
Criando o cabeçalho
Agora que entendemos os princípios básicos, vamos começar a construir nosso cabeçalho dinâmico. Começaremos com uma página da Web dividida em seções. Esta imagem mostra o layout completo da página que vamos construir:
Incluí uma demonstração no final deste artigo, portanto, sinta-se à vontade para pular direto para ela se quiser desfazer o código. (Há também um repositório Github.)
Cada seção tem uma altura mínima de 100vh
(embora possam ser mais longas, dependendo do conteúdo). Nosso cabeçalho é fixado na parte superior da página e permanece no lugar enquanto o usuário rola (usando position: fixed
). As seções têm fundos de cores diferentes e, quando encontram o cabeçalho, as cores do cabeçalho mudam para complementar as da seção. Há também um marcador para mostrar a seção atual em que o usuário está, que desliza quando a próxima seção chega. Para facilitar o acesso direto ao código relevante, configurei uma demonstração mínima com nosso ponto de partida (antes de começarmos a usar a API Intersection Observer), caso você queira acompanhar.
Marcação
Começaremos com o HTML para nosso cabeçalho. Este será um cabeçalho bastante simples com um link inicial e navegação, nada especialmente sofisticado, mas usaremos alguns atributos de dados: data-header
para o próprio cabeçalho (para que possamos direcionar o elemento com JS) , e três links de âncora com o atributo data-link
, que rolará o usuário para a seção relevante quando clicado:
<header data-header> <nav class="header__nav"> <div class="header__left-content"> <a href="#0">Home</a> </div> <ul class="header__list"> <li> <a href="#about-us" data-link>About us</a> </li> <li> <a href="#flavours" data-link>The flavours</a> </li> <li> <a href="#get-in-touch" data-link>Get in touch</a> </li> </ul> </nav> </header>
Em seguida, o HTML para o resto da nossa página, que é dividido em seções. Para ser breve, incluí apenas as partes relevantes para o artigo, mas a marcação completa está incluída na demonstração. Cada seção inclui um atributo de dados especificando o nome da cor de fundo e um id
que corresponde a um dos links âncora no cabeçalho:
<main> <section data-section="raspberry"> <!--Section content--> </section> <section data-section="mint"> <!--Section content--> </section> <section data-section="vanilla"> <!--Section content--> </section> <section data-section="chocolate"> <!--Section content--> </section> </main>
Vamos posicionar nosso cabeçalho com CSS para que ele fique fixo no topo da página enquanto o usuário rola:
header { position: fixed; width: 100%; }
Também daremos às nossas seções uma altura mínima e centralizaremos o conteúdo. (Este código não é necessário para o Intersection Observer funcionar, é apenas para o design.)
section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }
Aviso de iframe
Ao construir esta demonstração do Codepen, me deparei com um problema desconcertante em que meu código Intersection Observer que deveria ter funcionado perfeitamente estava falhando ao disparar o retorno de chamada no ponto correto da interseção, mas disparando quando o elemento alvo cruzava com a borda da viewport. Depois de um pouco de coçar a cabeça, percebi que isso acontecia porque no Codepen o conteúdo é carregado dentro de um iframe, que é tratado de forma diferente. (Consulte a seção dos documentos do MDN sobre Recorte e o retângulo de interseção para obter detalhes completos.)
Como solução alternativa, na demonstração, podemos envolver nossa marcação em outro elemento, que atuará como o contêiner de rolagem - a raiz em nossas opções de E/S - em vez da janela de visualização do navegador, como poderíamos esperar:
<div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>
Se você quiser ver como usar a janela de visualização como raiz para a mesma demonstração, isso está incluído no repositório do Github.
CSS
Em nosso CSS, definiremos algumas propriedades personalizadas para as cores que estamos usando. Também definiremos duas propriedades personalizadas adicionais para o texto do cabeçalho e as cores do plano de fundo e definiremos alguns valores iniciais. (Vamos atualizar essas duas propriedades personalizadas para as diferentes seções mais tarde.)
:root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }
Usaremos essas propriedades personalizadas em nosso cabeçalho:
header { background-color: var(--headerBg); color: var(--headerText); }
Também definiremos as cores para nossas diferentes seções. Estou usando os atributos de dados como seletores, mas você pode usar uma classe com a mesma facilidade, se preferir.
[data-section="raspberry"] { background-color: var(--raspberry); color: var(--vanilla); } [data-section="mint"] { background-color: var(--mint); color: var(--chocolate); } [data-section="vanilla"] { background-color: var(--vanilla); color: var(--chocolate); } [data-section="chocolate"] { background-color: var(--chocolate); color: var(--vanilla); }
Também podemos definir alguns estilos para nosso cabeçalho quando cada seção estiver em exibição:
/* Header */ [data-theme="raspberry"] { --headerText: var(--raspberry); --headerBg: var(--vanilla); } [data-theme="mint"] { --headerText: var(--mint); --headerBg: var(--chocolate); } [data-theme="chocolate"] { --headerText: var(--chocolate); --headerBg: var(--vanilla); }
Há um caso mais forte para usar atributos de dados aqui porque vamos alternar o atributo data-theme
do cabeçalho em cada interseção.
Criando o Observador
Agora que temos o HTML e CSS básicos para nossa página configurados, podemos criar um observador para observar cada uma de nossas seções que estão sendo exibidas. Queremos disparar um retorno de chamada sempre que uma seção entrar em contato com a parte inferior do cabeçalho enquanto rolamos a página. Isso significa que precisamos definir uma margem de raiz negativa que corresponda à altura do cabeçalho.
const header = document.querySelector('[data-header]') const sections = [...document.querySelectorAll('[data-section]')] const scrollRoot = document.querySelector('[data-scroller]') const options = { root: scrollRoot, rootMargin: `${header.offsetHeight * -1}px`, threshold: 0 }
Estamos definindo um limite de 0 , pois queremos que ele seja acionado se qualquer parte da seção estiver fazendo interseção com a margem da raiz.
Em primeiro lugar, criaremos um retorno de chamada para alterar o valor do data-theme
do cabeçalho. (Isso é mais direto do que adicionar e remover classes, especialmente quando nosso elemento de cabeçalho pode ter outras classes aplicadas.)
/* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }
Em seguida, criaremos o observador para observar as seções que se cruzam:
/* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })
Agora devemos ver nossas cores de cabeçalho atualizadas quando cada seção encontra o cabeçalho.
No entanto, você pode perceber que as cores não estão sendo atualizadas corretamente à medida que rolamos para baixo. Na verdade, o cabeçalho está sempre atualizando com as cores da seção anterior! Rolar para cima, por outro lado, funciona perfeitamente. Precisamos determinar a direção de rolagem e alterar o comportamento de acordo.
Encontrando a direção de rolagem
Vamos definir uma variável em nosso JS para a direção de rolagem, com um valor inicial de 'up'
e outra para a última posição de rolagem conhecida ( prevYPosition
). Então, dentro do retorno de chamada, se a posição de rolagem for maior que o valor anterior, podemos definir o valor da direction
como 'down'
ou 'up'
se vice-versa.
let direction = 'up' let prevYPosition = 0 const setScrollDirection = () => { if (scrollRoot.scrollTop > prevYPosition) { direction = 'down' } else { direction = 'up' } prevYPosition = scrollRoot.scrollTop } const onIntersect = (entries, observer) => { entries.forEach((entry) => { setScrollDirection() /* ... */ }) }
Também criaremos uma nova função para atualizar as cores do cabeçalho, passando a seção de destino como argumento:
const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }
Até agora não devemos ver nenhuma mudança no comportamento do nosso cabeçalho. Mas agora que sabemos a direção de rolagem, podemos passar um destino diferente para nossa função updateColors()
. Se a direção de rolagem for para cima, usaremos o destino de entrada. Se estiver inativo, usaremos a próxima seção (se houver).
const getTargetSection = (target) => { if (direction === 'up') return target if (target.nextElementSibling) { return target.nextElementSibling } else { return target } } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() const target = getTargetSection(entry.target) updateColors(target) }) }
Há mais um problema, no entanto: o cabeçalho será atualizado não apenas quando a seção atingir o cabeçalho, mas quando o próximo elemento for exibido na parte inferior da janela de visualização. Isso ocorre porque nosso observador dispara o retorno de chamada duas vezes: uma vez quando o elemento está entrando e novamente quando está saindo.
Para determinar se o cabeçalho deve ser atualizado, podemos usar a chave isIntersecting
do objeto entry
. Vamos criar outra função para retornar um valor booleano para saber se as cores do cabeçalho devem ser atualizadas:
const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }
Atualizaremos nossa função onIntersect()
acordo:
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() /* Do nothing if no need to update */ if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) }) }
Agora nossas cores devem ser atualizadas corretamente. Podemos definir uma transição CSS, para que o efeito seja um pouco melhor:
header { transition: background-color 200ms, color 200ms; }
Adicionando o marcador dinâmico
Em seguida, adicionaremos um marcador ao cabeçalho que atualiza sua posição à medida que rolamos para as diferentes seções. Podemos usar um pseudo-elemento para isso, então não precisamos adicionar nada ao nosso HTML. Vamos dar-lhe um estilo CSS simples para posicioná-lo no canto superior esquerdo do cabeçalho e dar-lhe uma cor de fundo. Estamos usando currentColor
para isso, pois ele assumirá o valor da cor do texto do cabeçalho:
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }
Podemos usar uma propriedade personalizada para a largura, com um valor padrão de 0. Também usaremos uma propriedade personalizada para o valor x de tradução. Vamos definir os valores para eles em nossa função de retorno de chamada à medida que o usuário rola.
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; width: var(--markerWidth, 0); background-color: currentColor; transform: translate3d(var(--markerLeft, 0), 0, 0); }
Agora podemos escrever uma função que atualizará a largura e a posição do marcador no ponto de interseção:
const updateMarker = (target) => { const id = target.id /* Do nothing if no target ID */ if (!id) return /* Find the corresponding nav link, or use the first one */ let link = headerLinks.find((el) => { return el.getAttribute('href') === `#${id}` }) link = link || headerLinks[0] /* Get the values and set the custom properties */ const distanceFromLeft = link.getBoundingClientRect().left header.style.setProperty('--markerWidth', `${link.clientWidth}px`) header.style.setProperty('--markerLeft', `${distanceFromLeft}px`) }
Podemos chamar a função ao mesmo tempo que atualizamos as cores:
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }
Também precisaremos definir uma posição inicial para o marcador, para que ele não apareça do nada. Quando o documento for carregado, chamaremos a função updateMarker()
, usando a primeira seção como destino:
document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })
Por fim, vamos adicionar uma transição CSS para que o marcador deslize pelo cabeçalho de um link para o outro. Como estamos fazendo a transição da propriedade width
, podemos usar will-change
para permitir que o navegador realize otimizações.
header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }
Rolagem suave
Para um toque final, seria bom se, quando um usuário clicar em um link, ele fosse rolado suavemente pela página, em vez de pular para a seção. Hoje em dia podemos fazer isso direto em nosso CSS, sem necessidade de JS! Para uma experiência mais acessível, é uma boa ideia respeitar as preferências de movimento do usuário implementando apenas a rolagem suave se ele não tiver especificado uma preferência por movimento reduzido nas configurações do sistema:
@media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }
Demonstração final
Juntar todas as etapas acima resulta na demonstração completa.
Suporte ao navegador
O Intersection Observer é amplamente suportado em navegadores modernos. Sempre que necessário, pode ser polyfilled para navegadores mais antigos - mas prefiro adotar uma abordagem de aprimoramento progressivo sempre que possível. No caso do nosso cabeçalho, não seria muito prejudicial para a experiência do usuário fornecer uma versão simples e imutável para navegadores não compatíveis.
Para detectar se o Intersection Observer é suportado, podemos usar o seguinte:
if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { /* Code to execute if IO is supported */ } else { /* Code to execute if not supported */ }
Recursos
Leia mais sobre o Observador de Interseção:
- Extensa documentação, com alguns exemplos práticos do MDN
- Ferramenta de visualização do observador de interseção
- Visibilidade do elemento de tempo com a API Intersection Observer – outro tutorial da MDN, que analisa como o IO pode ser usado para rastrear a visibilidade do anúncio
- Este artigo de Denys Mishunov aborda alguns outros usos para IO, incluindo ativos de carregamento lento. Embora isso seja menos necessário agora (graças ao atributo
loading
), ainda há muito o que aprender aqui.