Marcos N
← Voltar ao blog

JavaScript e CSS

Como animar elementos no scroll com IntersectionObserver

Crie animações de entrada com JavaScript e CSS, usando IntersectionObserver, movimento reduzido e conteúdo acessível mesmo sem JavaScript.

Marcos N7 min de leitura
Como animar elementos no scroll com IntersectionObserver

Para animar um elemento quando ele entra na tela, use IntersectionObserver para detectar sua interseção com a área visível e CSS para executar a transição. Você não precisa calcular a posição de todos os cards em cada evento de scroll.

Neste tutorial, vamos criar uma entrada curta para cards de projetos. Cada card anima uma vez. Sem JavaScript ou com preferência por movimento reduzido, o conteúdo permanece disponível.

Essa solução serve para revelar elementos em um ponto da navegação. Ela não sincroniza cada frame da animação com o progresso do scroll.

Como funciona o IntersectionObserver?

Um observador acompanha a interseção entre um elemento e uma área de referência. Com root: null, essa referência é o viewport. O callback recebe entradas quando os limites configurados são cruzados, incluindo uma notificação inicial sobre o estado observado.

É importante verificar isIntersecting, em vez de assumir que toda chamada significa que o elemento acabou de aparecer. A documentação da API na MDN detalha esse comportamento e as opções do observador.

Vamos usar um único observador para vários cards e parar de observar cada um depois da entrada.

1. Monte o HTML com conteúdo visível

Crie os três arquivos abaixo na mesma pasta. Abra index.html por um servidor local para testar. O espaço inicial é apenas para demonstrar a entrada dos cards durante a rolagem.

index.html
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Cards com animação de entrada</title>
  <link rel="stylesheet" href="styles.css" />
  <script src="reveal.js" defer></script>
</head>
<body>
  <main>
    <header class="intro">
      <h1>Projetos selecionados</h1>
      <p>Role a página para conhecer os projetos.</p>
    </header>
    <section aria-label="Lista de projetos" class="projects">
      <article class="card" data-reveal>
        <h2>Website institucional</h2>
        <p>Organização de conteúdo e implementação responsiva.</p>
        <a href="#contato">Conversar sobre um website</a>
      </article>
      <article class="card" data-reveal>
        <h2>Interface de produto</h2>
        <p>Componentes, estados e navegação para tarefas recorrentes.</p>
        <a href="#contato">Conversar sobre uma interface</a>
      </article>
    </section>
    <section id="contato">
      <h2>Contato</h2>
      <p>Inclua aqui o seu canal de atendimento.</p>
    </section>
  </main>
</body>
</html>

Nenhuma classe do HTML esconde os cards. Esse é o estado básico da página: o conteúdo não depende do sucesso de um script para aparecer.

2. Defina o visual e a transição

styles.css
* { box-sizing: border-box; }
body {
  margin: 0;
  background: #f7f7f2;
  color: #171717;
  font-family: system-ui, sans-serif;
}
main { max-width: 56rem; margin: auto; padding: 2rem 1.5rem; }
.intro { min-height: 85svh; padding-top: 3rem; }
.projects { display: grid; gap: 2rem; }
.card { padding: 2rem; border-radius: 1rem; background: white; }
a { color: #6330ce; }
a:focus-visible { outline: 3px solid #7545ff; outline-offset: 4px; }
#contato { padding-block: 5rem; }

@media (prefers-reduced-motion: no-preference) {
  [data-reveal].is-pending {
    opacity: 0;
    transform: translateY(20px);
  }
  [data-reveal].is-ready {
    transition: opacity 450ms ease, transform 450ms ease;
  }
  [data-reveal].is-pending:focus-within {
    opacity: 1;
    transform: none;
  }
}

A regra :focus-within é importante porque o card contém um link. Se alguém navegar com Tab até ele antes da entrada visual, o card precisa aparecer. opacity: 0 não remove um elemento da navegação por teclado.

O deslocamento é pequeno e não altera a posição que o card ocupa no fluxo. Evite usar a animação para criar grandes esperas antes da leitura. Para entender os compromissos de implementação, veja também performance e acessibilidade em animações.

3. Observe e revele os cards

reveal.js
function initReveal(root = document) {
  const cards = [...root.querySelectorAll('[data-reveal]')];
  const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
  let observer;
  let frame = 0;

  function showAll() {
    cancelAnimationFrame(frame);
    observer?.disconnect();
    cards.forEach(card => card.classList.remove('is-pending', 'is-ready'));
  }

  function onFocus(event) {
    const card = event.target instanceof Element
      ? event.target.closest('[data-reveal]')
      : null;
    if (card && cards.includes(card)) {
      card.classList.remove('is-pending');
      observer?.unobserve(card);
    }
  }

  function start() {
    showAll();
    if (motion.matches || !('IntersectionObserver' in window)) return;

    observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (!entry.isIntersecting) return;
        entry.target.classList.remove('is-pending');
        observer.unobserve(entry.target);
      });
    }, { threshold: 0 });

    cards.forEach(card => {
      // Conteúdo já visível não precisa desaparecer para animar.
      if (card.getBoundingClientRect().top >= window.innerHeight) {
        card.classList.add('is-pending');
        observer.observe(card);
      }
    });

    frame = requestAnimationFrame(() => {
      cards.forEach(card => card.classList.add('is-ready'));
    });
  }

  root.addEventListener('focusin', onFocus);
  motion.addEventListener('change', start);
  start();

  return () => {
    showAll();
    root.removeEventListener('focusin', onFocus);
    motion.removeEventListener('change', start);
  };
}

const cleanupReveal = initReveal();
// Em uma aplicação com navegação cliente, chame cleanupReveal()
// quando a tela correspondente for desmontada.

O script só prepara cards abaixo da primeira tela. Os que já estão visíveis permanecem visíveis. A inicialização pode consultar a geometria dos elementos; a diferença é não repetir essa leitura manualmente a cada evento de scroll.

Ao entrar na área observada, o card perde is-pending. Como a posição final já é o estilo padrão, a transição acontece sem precisar registrar um estado adicional de visibilidade.

O listener de preferência reinicializa o comportamento se a configuração de movimento mudar. A função devolvida desfaz os listeners e o observador, útil quando você adapta esse código para uma aplicação com montagem e desmontagem de telas.

Por que threshold zero?

Nesse exemplo, basta começar a entrar no viewport. Um limite proporcional maior, como 0,5, pode atrasar a entrada e até ser impossível de atingir em um elemento muito alto em relação à tela.

Você pode ajustar rootMargin para alterar a região de detecção. Margens positivas expandem essa região; negativas a reduzem. Teste a escolha com cards altos, telas pequenas e rolagem rápida, não apenas com a composição mais favorável.

Erros comuns

Esconder tudo antes de saber se o script funciona

Colocar opacity: 0 diretamente na classe básica pode deixar conteúdo invisível se o JavaScript falhar. Aqui, a marcação original é legível e a classe que esconde aparece apenas na inicialização do efeito.

Usar o efeito em elementos interativos sem verificar foco

Um link invisível ainda pode receber foco. A solução precisa contemplar o teclado, e não somente o evento de scroll. No exemplo, tanto o CSS quanto o listener de foco tornam o card visível.

Criar um observador para cada item sem necessidade

Um observador pode acompanhar vários elementos com as mesmas opções. Reutilizá-lo simplifica a configuração e a limpeza.

Esperar uma animação contínua guiada pelo scroll

O observador dispara a entrada; a transição CSS segue sua própria duração. Para uma animação cujo progresso acompanha a rolagem, avalie uma solução específica e seus requisitos de compatibilidade.

Como verificar o resultado

  1. Desative JavaScript: os cards devem continuar legíveis.
  2. Ative movimento reduzido: não deve haver entrada animada.
  3. Navegue com Tab: o link focado e seu card devem aparecer.
  4. Role até o fim e volte: cada card deve ter animado uma única vez.
  5. Use uma tela pequena e aumente o texto: o conteúdo precisa continuar acessível.
  6. Execute a função de limpeza: nenhum card deve ficar oculto.

O exemplo observa os elementos presentes na inicialização. Se sua aplicação acrescenta cards depois, observe também os novos elementos ou reinicialize o efeito com a limpeza anterior. Essa responsabilidade deve ficar explícita para evitar um comportamento que só funciona no primeiro carregamento.

Perguntas frequentes

IntersectionObserver substitui qualquer listener de scroll?

Não. Ele resolve detecção de interseção. Medir velocidade, direção ou progresso contínuo da rolagem é outro problema.

Preciso de uma biblioteca de animação?

Para essa entrada simples, não. Bibliotecas fazem mais sentido quando você precisa coordenar várias sequências, estados e controles que justificam a dependência.

Posso usar em React?

Sim. Inicialize o comportamento dentro de um efeito, restrinja a busca a uma referência do componente e retorne a limpeza. Evite selecionar a página inteira quando o componente controla apenas uma seção.

Se estiver trabalhando com outras operações assíncronas na interface, continue com busca em React, debounce e cancelamento. Para conhecer minha atuação em interfaces, veja os projetos selecionados.