12k
All articles

Como Implementar Infinite Scroll em Vanilla JavaScript

Implemente infinite scroll em JavaScript puro com Intersection Observer, elemento sentinela, paginação, proteção de carregamento e acessibilidade.

OpenReplay Team
OpenReplay Team
Como Implementar Infinite Scroll em Vanilla JavaScript

Implemente infinite scroll em vanilla JavaScript com a Intersection Observer API: posicione um elemento sentinela no final da sua lista, observe-o e busque a próxima página de dados sempre que ele entrar na viewport.

Se você já construiu algo assim antes, conhece o modo de falha: você arrasta a barra de rolagem com um pouco mais de força e os mesmos dez itens aparecem na lista três vezes seguidas. Fazer a mecânica básica funcionar leva cerca de dez minutos; fazer com que ela sobreviva a um usuário real consome o resto da tarde. Isso substitui a antiga abordagem de evento scroll combinado com getBoundingClientRect, que executa cálculos de posição a cada tick de rolagem. Este guia constrói um feed completo e executável, com fetch real, paginação e append no DOM, e depois aborda as quatro armadilhas de produção (requisições duplicadas, nunca parar, tratamento de erros e timing de prefetch) além dos fallbacks de acessibilidade que separam uma demo de um código pronto para ir ao ar.

Principais Conclusões

  • Use IntersectionObserver, não eventos de scroll: um listener de scroll dispara continuamente na main thread e força cálculos manuais de posição, enquanto o observer executa um callback apenas quando o alvo realmente cruza a viewport.
  • O IntersectionObserver é Baseline em todos os navegadores modernos desde março de 2019, portanto infinite scroll não precisa de polyfill hoje em dia.
  • Proteja cada fetch com uma flag booleana para que uma rolagem rápida não dispare várias requisições sobrepostas antes que a primeira seja resolvida.
  • Pare quando a API retornar uma página curta ou vazia. Chame observer.disconnect() e oculte a sentinela, ou o observer continuará requisitando páginas que não existem mais.
  • Combine infinite scroll com um botão “Load more” visível: ele é, ao mesmo tempo, o fallback para teclado, para leitores de tela e para ausência de JavaScript.

Por que o IntersectionObserver supera os eventos de scroll?

Use IntersectionObserver em vez de um listener de scroll porque ele reporta visibilidade de forma assíncrona através de um callback que dispara apenas quando seu alvo cruza a viewport, em vez de rodar a cada frame de rolagem. O padrão antigo anexa um handler de scroll e chama getBoundingClientRect() a cada tick para calcular se o final da lista está próximo. Isso é cálculo de leitura de layout na main thread, executando muito mais vezes do que o necessário, e é uma fonte bem conhecida de scroll jank.

scroll + getBoundingClientRect()IntersectionObserver
DisparaA cada frame de rolagemApenas quando o alvo cruza a viewport
Cálculo de posiçãoManual, no seu códigoFeito pelo navegador
ThreadingSíncrono na main threadEntregue de forma assíncrona
Precisa de polyfilln/aNão (Baseline)

Nenhum polyfill é necessário. O MDN marca a API como Baseline Widely available, com suporte em todos os principais navegadores desde março de 2019, então recomendações antigas que sugerem um polyfill (e citam suporte da era do Chrome 51) estão desatualizadas. Uma exceção: não recorra a trackVisibility por padrão, pois o MDN ainda lista essa propriedade de detecção de oclusão como experimental e com disponibilidade limitada.

O que é o padrão sentinela?

O padrão sentinela posiciona um único elemento marcador no final da lista; quando o observer reporta que a sentinela entrou na viewport, você busca a próxima página e a acrescenta. A sentinela é apenas um elemento vazio depois do seu último item, e você nunca precisa reescolhê-la, porque acrescentar novos itens continua empurrando-a para baixo.

As três partes móveis:

  1. Construa o observer: new IntersectionObserver(callback, options).
  2. Comece a observar: observer.observe(sentinel).
  3. No callback, verifique entry.isIntersecting e carregue a próxima página quando for true.

Itere sobre o array entries em vez de ler entries[0]. A referência do construtor IntersectionObserver() alerta contra presumir qualquer quantidade específica de entradas, pois uma única execução do seu callback pode carregar vários cruzamentos de uma vez.

Um exemplo completo de infinite scroll em vanilla JavaScript

Abaixo está uma implementação completa e funcional usando o JSONPlaceholder, uma API REST mock gratuita que roda em JSON Server com LowDB por trás. Seu endpoint /posts contém 100 registros e aceita os parâmetros de query _page e _limit, retornando a fatia solicitada como um array simples. Isso oferece um dataset finito, o que é conveniente para demonstrar o que acontece quando os dados acabam.

A marcação: uma lista, um botão de fallback, uma sentinela e uma linha de status com live region.

<main>
  <ul id="list" aria-label="Posts"></ul>
  <button id="load-more" type="button">Load more</button>
  <div id="sentinel" aria-hidden="true"></div>
  <p id="status" role="status" aria-live="polite"></p>
</main>

O script conecta o observer à sentinela e busca uma página por interseção:

const LIMIT = 10;
let page = 1;
let loading = false;   // guard against overlapping requests
let done = false;      // stop at end of data

const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const loadMoreBtn = document.getElementById("load-more");
const status = document.getElementById("status");

async function fetchPosts(page) {
  const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${LIMIT}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

function render(posts) {
  const frag = document.createDocumentFragment();
  for (const post of posts) {
    const li = document.createElement("li");
    li.innerHTML = `<h2>${post.title}</h2><p>${post.body}</p>`;
    frag.appendChild(li);
  }
  list.appendChild(frag);
}

async function loadNextPage() {
  if (loading || done) return;
  loading = true;
  status.textContent = "Loading…";
  try {
    const posts = await fetchPosts(page);
    render(posts);
    page += 1;
    if (posts.length < LIMIT) {   // short/empty page = no more data
      done = true;
      observer.disconnect();
      loadMoreBtn.hidden = true;
      status.textContent = "You've reached the end.";
    } else {
      status.textContent = "";
    }
  } catch (err) {
    status.textContent = "Could not load posts. Tap Load more to retry.";
    console.error(err);
  } finally {
    loading = false;
  }
}

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) loadNextPage();
    }
  },
  { root: null, rootMargin: "200px", threshold: 0 }
);

observer.observe(sentinel);
loadMoreBtn.addEventListener("click", loadNextPage);
document.addEventListener("DOMContentLoaded", loadNextPage);

Toda requisição passa por loadNextPage, de modo que o callback do observer, o clique no botão e o carregamento inicial no DOMContentLoaded compartilham a mesma lógica de proteção e de parada.

As quatro armadilhas que separam uma demo de um código pronto para produção

A maioria dos tutoriais para em “ele acrescenta dados”. Estas quatro correções são o que fazem a implementação sobreviver a usuários reais.

SintomaCausaCorreção
Requisições duplicadas em rolagem rápidaAusência de guard de requisiçãoFlag booleana if (loading) return;
A lista nunca para, requisita páginas vaziasAusência de detecção de fimif (posts.length < LIMIT) observer.disconnect()
Erros somem silenciosamenteAusência de tratamento de erro no fetchVerificar res.ok, try/catch, expor uma opção de retry
Pausa visível no finalrootMargin: "0px"rootMargin: "200px" para fazer prefetch antecipado

Proteja-se contra requisições duplicadas. Um movimento rápido pode disparar o callback várias vezes antes que o primeiro await seja resolvido. A flag loading faz com que toda chamada extra retorne imediatamente até que a requisição em andamento se resolva no bloco finally. Você também pode usar unobserve na sentinela durante a requisição e voltar a observá-la depois. Só não confunda unobserve (um alvo) com disconnect (todos os alvos).

Pare no fim dos dados. Com uma fonte finita, se você continuar requisitando, vai bombardear páginas que não existem. Detecte uma página menor que LIMIT, o mesmo sinal de fim de dados usado no tutorial de loop sobre APIs paginadas da Prismatic, que interrompe o loop assim que uma requisição retorna um array vazio. Depois chame disconnect() e oculte a sentinela e o botão.

Prefira threshold: 0 com rootMargin. Definir rootMargin: "200px" inicia o próximo fetch aproximadamente 200 pixels antes de o usuário chegar ao final, eliminando a pausa visível. Combine isso com threshold: 0, e não 1.0: uma sentinela mais alta que a viewport pode nunca ficar 100% visível, então um threshold de visibilidade total pode silenciosamente nunca disparar.

Bugs de infinite scroll dependem de timing e da velocidade de rolagem, então uma rolagem local cuidadosa raramente os reproduz. Assistir a sessões reais através de uma ferramenta como o session replay é uma forma de expor essa classe de falha que permanece invisível em um teste rápido: requisições duplicadas em um movimento rápido, ou uma lista que nunca para.

Acessibilidade e o fallback “Load more”

Sempre combine infinite scroll com um botão “Load more” visível: ele é o fallback para teclado e leitores de tela, o fallback para ausência de JavaScript e, muitas vezes, a única forma de o usuário pausar o fluxo para alcançar o rodapé. Conteúdo com carregamento automático infinito aprisiona usuários de tecnologias assistivas, esconde links do rodapé atrás de conteúdo que não para de crescer e quebra a restauração da posição de rolagem no botão voltar, quando o usuário retorna a uma posição que já não existe no DOM.

Três passos concretos, todos presentes no código acima:

  • Anuncie o estado de carregamento por meio de uma live region: <p role="status" aria-live="polite"> permite que leitores de tela ouçam “Loading…” e “You’ve reached the end.”
  • Mantenha o botão como um controle real e focável, para que funcione quando o observer nunca disparar ou quando o JavaScript estiver desabilitado.
  • Marque a sentinela com aria-hidden="true". Ela é um mecanismo, não conteúdo, e não deve chegar à árvore de acessibilidade.

Se o rodapé de um feed realmente importa (links de contato, informações legais, paginação para deep links), avalie se um botão “Load more” sozinho não é o padrão mais adequado, reservando o carregamento automático para conteúdos em que um fluxo infinito é justamente a proposta.

Infinite scroll em vanilla JavaScript se resume a uma ideia duradoura: observe uma sentinela, busque dados na interseção e trate os casos-limite. Pegue o arquivo completo acima, aponte fetchPosts para o seu próprio endpoint paginado e confirme que tanto o guard quanto o caminho de parada no fim dos dados disparam antes de colocar em produção. Essas duas linhas são o que transformam uma demo funcional em código no qual você pode confiar em produção.

Perguntas Frequentes

Qual é a diferença entre unobserve e disconnect em um IntersectionObserver?

Chame unobserve quando quiser que o observer deixe de acompanhar um elemento específico e continue com os demais, e chame disconnect quando quiser que ele abandone tudo o que está observando no momento. Para infinite scroll, isso corresponde a unobserve para pausar a sentinela única enquanto uma requisição está em andamento, e disconnect quando os dados acabam e o observer não tem mais nenhuma tarefa a cumprir.

Quando devo usar paginação ou um botão Load more em vez de infinite scroll?

Escolha paginação ou um botão Load more quando o rodapé importa, como links de contato, texto legal ou paginação para deep links, porque o carregamento automático infinito empurra o conteúdo do rodapé permanentemente para fora de alcance e aprisiona usuários de teclado e de leitores de tela. Infinite scroll é adequado para conteúdo em aberto, no qual um fluxo infinito é a proposta, como feeds sociais. Quando os usuários precisam de um ponto de parada ou precisam alcançar o final da página, um controle explícito é o padrão mais adequado.

Por que um threshold de 1.0 às vezes falha em acionar o infinite scroll?

Um threshold de 1.0 exige que o elemento observado esteja 100 por cento visível antes de o callback disparar, então uma sentinela mais alta que a viewport nunca entra completamente em vista e o callback silenciosamente nunca é executado. Use threshold 0 combinado com um buffer de rootMargin: o callback então dispara assim que qualquer parte da sentinela cruza o limite expandido da root, o que é o padrão mais confiável para infinite scroll.

Preciso tratar múltiplas entries no callback do IntersectionObserver?

Sim. A referência do construtor no MDN orienta a não depender de o array entries ter qualquer tamanho específico, porque uma única execução do seu callback pode carregar mais de um cruzamento. Para uma sentinela única, entries[0] costuma funcionar na prática, mas iterar sobre todas as entries e verificar isIntersecting em cada uma é a abordagem correta e evita interseções perdidas ou atribuídas incorretamente quando mais de um alvo reporta ao mesmo tempo.

O infinite scroll quebra o botão voltar do navegador?

Sim, infinite scroll pode quebrar a restauração da posição de rolagem no botão voltar, porque o navegador tenta devolver o usuário a uma posição de rolagem que já não existe no DOM depois que o conteúdo carregado dinamicamente é descartado na navegação. O usuário acaba no lugar errado ou no topo da lista. Formas de mitigar incluem persistir o estado carregado, restaurar a posição de rolagem manualmente ou oferecer um botão Load more, de modo que a navegação corresponda a um estado estável e reproduzível.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.