Marcos N
← Voltar ao blog

Animação e front-end

ScrollTrigger vs IntersectionObserver: quando usar

Compare ScrollTrigger e IntersectionObserver com exemplos em TypeScript, limites de cada abordagem e decisões práticas de performance e acessibilidade.

Marcos N6 min de leitura
ScrollTrigger vs IntersectionObserver: quando usar

Um card precisa aparecer ao entrar na tela. Outra seção precisa manter uma ilustração fixa e sincronizar seu movimento com a rolagem. Comparar ScrollTrigger vs IntersectionObserver começa reconhecendo que essas demandas têm contratos diferentes.

No primeiro caso, importa detectar uma condição. No segundo, importa representar progresso. Escolher pela popularidade da ferramenta pode acrescentar complexidade a uma tarefa pequena ou forçar uma API a resolver algo para o qual não foi desenhada.

ScrollTrigger vs IntersectionObserver: a diferença central

IntersectionObserver observa de forma assíncrona mudanças na interseção de um elemento com uma raiz ou com o viewport. Seus thresholds indicam proporções de interseção relevantes; a API não foi feita para entregar a posição exata de cada pixel a cada frame. Documentação de IntersectionObserver.

ScrollTrigger oferece recursos como scrub, pin e associação de animações a intervalos de scroll. Isso permite compor uma narrativa cujo avanço depende da rolagem. Documentação de ScrollTrigger.

Minha pergunta de escolha é: a animação deve começar quando algo acontece ou permanecer vinculada ao avanço do gesto?

Exemplo com IntersectionObserver: uma revelação pontual

O hook abaixo acompanha um único elemento. Ele usa a Web Animations API para animar uma entrada curta, preservando o conteúdo visível quando JavaScript não funciona. Não existe uma classe inicial escondendo a informação.

RevealCard.tsx
'use client';

import { useEffect, useRef, type ReactNode } from 'react';

export function RevealCard({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element || !('IntersectionObserver' in window)) return;

    const preference = matchMedia('(prefers-reduced-motion: reduce)');
    if (preference.matches) return;

    let animation: Animation | undefined;
    const observer = new IntersectionObserver(entries => {
      const entry = entries[0];
      if (!entry?.isIntersecting || preference.matches) return;

      animation = element.animate(
        [{ opacity: 0.7, transform: 'translateY(12px)' },
         { opacity: 1, transform: 'translateY(0)' }],
        { duration: 260, easing: 'ease-out' },
      );
      observer.disconnect();
    }, { threshold: 0.15 });

    const stopMotion = () => {
      if (preference.matches) {
        observer.disconnect();
        animation?.cancel();
      }
    };

    observer.observe(element);
    preference.addEventListener('change', stopMotion);
    return () => {
      observer.disconnect();
      animation?.cancel();
      preference.removeEventListener('change', stopMotion);
    };
  }, []);

  return <div ref={ref}>{children}</div>;
}

Use esse componente em cards de altura moderada. O threshold considera a proporção do elemento: um bloco muito maior que a tela pode não alcançar 0.15. Esse é um motivo para relacionar o parâmetro ao conteúdo real, em vez de copiá-lo como um número universal.

A estratégia também tem uma escolha visual explícita: a animação começa quando o elemento já está parcialmente visível. Eu aceitaria essa entrada discreta para conteúdo secundário; não aplicaria ao título principal para fazê-lo “esperar” pelo efeito.

Exemplo com ScrollTrigger: progresso ligado ao scroll

Aqui, um marcador decorativo acompanha o progresso dentro de uma seção. O exemplo pressupõe GSAP instalado. Não usa Lenis e não precisa dele para funcionar.

ScrollProgress.tsx
'use client';

import { useEffect, useRef, type ReactNode } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

export function ScrollProgress({ children }: { children: ReactNode }) {
  const sectionRef = useRef<HTMLElement>(null);
  const indicatorRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const section = sectionRef.current;
    const indicator = indicatorRef.current;
    if (!section || !indicator) return;

    gsap.registerPlugin(ScrollTrigger);
    const media = gsap.matchMedia();
    media.add('(prefers-reduced-motion: no-preference)', () => {
      gsap.fromTo(indicator,
        { scaleX: 0 },
        {
          scaleX: 1,
          ease: 'none',
          scrollTrigger: {
            trigger: section,
            start: 'top bottom',
            end: 'bottom top',
            scrub: true,
          },
        },
      );
    });

    return () => media.revert();
  }, []);

  return (
    <section ref={sectionRef}>
      <div
        ref={indicatorRef}
        aria-hidden="true"
        style={{ height: 3, background: '#7545ff', transformOrigin: 'left' }}
      />
      {children}
    </section>
  );
}

O marcador avança do momento em que o topo entra no viewport até a saída do fim da seção. É uma medida visual da travessia da seção, não uma confirmação de que a pessoa leu o texto.

gsap.matchMedia() registra as animações criadas no contexto da condição e permite revertê-las quando ela deixa de valer ou na limpeza. Isso evita manter a animação ativa depois de uma mudança de preferência. Referência de matchMedia.

Qual abordagem eu escolheria?

DemandaMinha escolha inicialMotivo
Revelar um card uma vezIntersectionObserverPreciso de um evento de entrada
Saber se uma área está próxima da telaIntersectionObserverA interseção é a informação relevante
Sincronizar vários elementos ao progressoScrollTriggerPreciso de uma timeline vinculada ao scroll
Fixar uma coluna de textoCSS stickyNão preciso de uma animação para isso
Fixar e controlar uma cena narrativaScrollTriggerPin e intervalo fazem parte da composição
Demanda
Revelar um card uma vez
Minha escolha inicial
IntersectionObserver
Motivo
Preciso de um evento de entrada
Demanda
Saber se uma área está próxima da tela
Minha escolha inicial
IntersectionObserver
Motivo
A interseção é a informação relevante
Demanda
Sincronizar vários elementos ao progresso
Minha escolha inicial
ScrollTrigger
Motivo
Preciso de uma timeline vinculada ao scroll
Demanda
Fixar uma coluna de texto
Minha escolha inicial
CSS sticky
Motivo
Não preciso de uma animação para isso
Demanda
Fixar e controlar uma cena narrativa
Minha escolha inicial
ScrollTrigger
Motivo
Pin e intervalo fazem parte da composição

Não chamaria uma opção de “mais performática” sem definir o trabalho. Um observer com callback pesado pode prejudicar a página. Uma cena pequena com ScrollTrigger pode funcionar bem. Área animada, propriedades alteradas, quantidade de elementos e custo do código também importam.

Quando eu usaria isso em um projeto real?

Num blog, minha escolha inicial seria conteúdo estável e poucas animações. Usaria observer para uma entrada secundária e preservaria a leitura sem depender do efeito.

Numa apresentação de produto, consideraria ScrollTrigger se o gesto precisasse explicar uma sequência: etapas de montagem, comparação visual ou relação entre partes. Antes de implementar, desenharia a versão mobile e a alternativa com movimento reduzido.

Se a narrativa depende de prender o usuário por várias alturas de tela, testaria a sensação de controle. Um efeito pode estar tecnicamente sincronizado e ainda parecer uma barreira para chegar à informação.

Erros comuns

Criar centenas de thresholds para imitar scrub

Isso transforma um observador de interseções em um controlador de progresso aproximado. Eu escolheria uma ferramenta alinhada à precisão necessária, em vez de compensar a diferença com mais callbacks.

Esconder todo o conteúdo antes da inicialização

Se o script falhar, o leitor pode perder informações essenciais. Prefiro partir de conteúdo visível e acrescentar tratamento visual como melhoria.

Esquecer a limpeza

Observers, animações e listeners precisam acompanhar o ciclo de vida do componente. Navegar repetidamente entre rotas é um teste simples para identificar duplicações e estados residuais.

Animar o mesmo transform em dois lugares

Se CSS e GSAP controlam o mesmo elemento ao mesmo tempo, podem disputar a propriedade. Eu separaria camadas: um wrapper para a composição e outro para o efeito, quando necessário.

Ignorar mudanças no layout

Uma imagem carregada depois pode alterar a geometria da cena. Reserve dimensões e revise o momento de atualizar medições. Evite usar atualizações contínuas como compensação para uma estrutura instável.

Conclusão

Na comparação ScrollTrigger vs IntersectionObserver, a escolha melhora quando você define primeiro a relação entre gesto e resposta. Observe entradas quando precisar de eventos; use uma timeline quando precisar de progresso. E mantenha o conteúdo compreensível mesmo quando a animação não participa da experiência.