IntersectionObserver: threshold, rootMargin e scrollspy
IntersectionObserver na prática: por que a 1ª chamada vem para todos, threshold, rootMargin para scrollspy, root em painel e quando o CSS view() basta.
O IntersectionObserver avisa quando um elemento cruza uma área que você define, em geral a janela visível. Você não mede posição a cada evento de rolagem: o navegador calcula as interseções no ciclo de renderização e só chama a sua função quando algo atravessa um dos limites configurados. O que confunde quem começa a usar são três opções (root, rootMargin e threshold) e o comportamento da primeira chamada. Este guia mostra cada uma com uma página de teste rodada no Chrome 156 em modo headless, e termina com o caso em que nem JavaScript é preciso.
O que a função recebe, e por que ela roda logo no início
A função do observador recebe uma lista de IntersectionObserverEntry, uma para cada elemento que mudou de estado desde a última entrega. Os campos que mais importam:
target: o elemento observado.isIntersecting: se ele está dentro da área agora.intersectionRatio: a fração visível, de 0 a 1.boundingClientRecterootBounds: os retângulos já calculados, sem custo extra de layout.
Um detalhe que pega muita gente: logo depois de observe(), o navegador entrega uma entrada inicial para cada elemento, mesmo os que estão fora da tela. Na página de teste, com três seções de 900 px numa janela de 713 px de altura, a primeira entrega foi esta:
o1 s1 isI=true r=0.77 o1 s2 isI=false r=0.00 o1 s3 isI=false r=0.00
Por isso a função precisa sempre conferir isIntersecting. Código que trata toda chamada como "apareceu" dispara a ação para elementos que ninguém viu.
threshold: quanto do elemento precisa aparecer
O threshold é um número ou uma lista de números entre 0 e 1. Com o padrão 0, o aviso vem quando o primeiro pixel entra. Com 0.5, quando metade está visível. Com uma lista como [0, 0.5, 1], a função roda a cada vez que a fração cruza um desses pontos, em qualquer direção.
Use 1 com cuidado: um elemento mais alto que a janela nunca fica 100% visível, e o aviso nunca chega. Para blocos grandes, prefira uma fração baixa ou mude a área com rootMargin.
Depois de rolar a página de teste até 1.000 px, a segunda entrega trouxe só o que mudou:
o1 s1 isI=false r=0.00 o1 s2 isI=true r=0.79
A terceira seção não apareceu na lista porque não cruzou nenhum limite. É isso que torna o observador barato: o silêncio não custa nada.
rootMargin: aumentar ou encolher a área observada
O rootMargin usa a mesma ordem do margin do CSS (topo, direita, base, esquerda) e aceita pixels ou porcentagem da área. Valor positivo aumenta a área; negativo encolhe. Dois usos resolvem quase tudo:
'0px 0px 400px 0px': a área se estende 400 px abaixo da tela. Serve para começar a buscar dados antes de o usuário chegar ao fim da lista.'-50% 0px -50% 0px': a área vira uma linha no meio da tela. Serve para saber qual seção está sendo lida, o famoso scrollspy de sumário.
A figura mostra as duas áreas na mesma janela:

Sumário que marca a seção atual
Com a linha no meio da tela, só uma seção cruza a área por vez, e a marcação não pisca entre duas:
const links = new Map(
[...document.querySelectorAll('nav.sumario a')].map((a) => [a.hash.slice(1), a])
);
const espiao = new IntersectionObserver((entradas) => {
for (const e of entradas) {
if (!e.isIntersecting) continue;
links.forEach((a) => a.removeAttribute('aria-current'));
links.get(e.target.id)?.setAttribute('aria-current', 'true');
}
}, { rootMargin: '-50% 0px -50% 0px' });
document.querySelectorAll('main section[id]').forEach((s) => espiao.observe(s));
No teste, a marcação começou em s1 e passou para s2 depois da rolagem, sem nenhum evento de scroll. Use aria-current em vez de só uma classe: o leitor de tela anuncia o item atual e o CSS pode estilizar com [aria-current]. Se o sumário fica num menu fixo no topo, o cuidado com o espaço que ele ocupa está em menu fixo no topo com position: sticky.
root: observar dentro de uma caixa com rolagem própria
Sem root, a área é a janela do navegador. Se a lista está dentro de um painel com overflow: auto (um chat, uma coluna lateral, um modal), passe o próprio painel como root. O elemento observado precisa ser descendente dele.
const painel = document.getElementById('mensagens');
const obs = new IntersectionObserver(tratar, { root: painel, rootMargin: '200px 0px' });
No teste, um painel de 400 px com três itens de 300 px começou com os dois primeiros visíveis. Depois de painel.scrollTop = 500, o primeiro saiu e o terceiro entrou, e a janela principal nem se mexeu. Sem o root, o observador olharia a janela, onde o painel inteiro pode estar visível o tempo todo, e o aviso nunca chegaria.
Carregar mais itens sem rolagem infinita presa
Observe o botão "Carregar mais", e não um div vazio. O botão funciona por clique e teclado, e o observador só antecipa o clique:
const botao = document.getElementById('carregar-mais');
let carregando = false;
async function carregarMais() {
if (carregando) return;
carregando = true;
try {
const resp = await fetch(botao.dataset.proxima);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
document.getElementById('lista').insertAdjacentHTML('beforeend', await resp.text());
const proxima = resp.headers.get('X-Proxima-Pagina');
if (proxima) botao.dataset.proxima = proxima; else botao.remove();
} catch {
botao.textContent = 'Falhou. Tentar de novo';
} finally {
carregando = false;
}
}
botao.addEventListener('click', carregarMais);
new IntersectionObserver((e) => { if (e[0].isIntersecting) carregarMais(); },
{ rootMargin: '0px 0px 400px 0px' }).observe(botao);
A trava carregando evita dois pedidos para a mesma página quando a pessoa rola rápido. Se o servidor pagina com OFFSET, itens inseridos durante a leitura podem aparecer duplicados entre uma página e outra; o motivo e a troca por cursor estão em paginação por cursor no SQL. E se a lista nunca acaba, o rodapé fica inalcançável; as alternativas estão em rolagem infinita sem prender o rodapé.
Animar a entrada: CSS já resolve em boa parte dos navegadores
Para o efeito "o bloco aparece com fade ao entrar na tela", o padrão antigo era observador mais classe CSS. Hoje existe um caminho sem JavaScript: a função view() do animation-timeline, parte das animações guiadas por rolagem. Ela liga o progresso da animação à posição do elemento na área visível.
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.revela {
animation: revela linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
}
@keyframes revela { from { opacity: 0; translate: 0 2rem; } }
Chrome e Edge têm o recurso desde a versão 115, e o Safari desde a 26. Em outubro de 2026 o MDN ainda o marca como de disponibilidade limitada, então o @supports é obrigatório: onde não há suporte, o bloco aparece direto, sem animação, o que é um resultado aceitável. Repare que animation-timeline vem depois do atalho animation, porque o atalho redefine o timeline para auto.
O observador continua sendo a escolha quando a ação não é visual: buscar dados, registrar que uma seção foi vista, pausar um vídeo que saiu da tela ou contar um número uma vez só. Nos dois casos, respeite prefers-reduced-motion; o porquê está em animação que respeita quem pede menos movimento.
Quando não usar o observador
- Imagens abaixo da dobra:
<img loading="lazy">já adia o download, como descreve a referência do elemento img. Ponhawidtheheightpara a página não pular. - Barra de progresso de leitura: ela muda a cada pixel. Use
animation-timeline: scroll()onde houver suporte, ou o evento de scroll comrequestAnimationFramee{ passive: true }. - Medir tempo exato de leitura: o observador diz quando algo entrou e saiu, não se a pessoa estava olhando. Não use isso para inflar métricas.
Limpeza e números que valem guardar
Chame unobserve(el) quando a ação é de uma vez só, e disconnect() ao desmontar o componente (no useEffect do React, no onUnmounted do Vue). Um observador pode vigiar centenas de elementos; crie um por configuração, não um por elemento. A especificação completa, com a ordem exata de cálculo, está no rascunho do W3C, e a visão geral com exemplos está na página da API no MDN.
Em resumo: confira isIntersecting em toda chamada, porque a primeira vem para todos; use threshold baixo para blocos altos; rootMargin positivo antecipa carregamento e negativo vira scrollspy; root é obrigatório em painel com rolagem própria; e para efeito visual de entrada, tente primeiro o CSS com @supports.
Referência: http://pt.stackoverflow.com/questions/41088/descobrir-posicao-do-scroll-de-um-determinado-objeto