Toda vez que preciso animar algo no scroll bate a mesma dúvida: GSAP, IntersectionObserver ou animation-timeline nativo? Fui comparar os três a fundo.

Recentemente precisei fazer uma animação disparada pelo scroll e fui pesquisar se existia alguma forma nativa de fazer isso em CSS. Foi assim que encontrei o animation-timeline. Resolvi investigar mais a fundo pra entender os prós e contras desse recurso comparado a outras formas de fazer a mesma coisa, como GSAP ScrollTrigger, IntersectionObserver ou o listener JS manual que eu costumava usar.

No caminho esbarrei numa contradição interessante entre o que a especificação garante de fato e o que a maioria dos artigos por aí repete sobre rodar “inteiramente fora do main thread”. Vem comigo que a resposta é mais sutil do que parece, e no fim você sai com um comparativo direto entre as abordagens, os números de performance que consegui confirmar (e os que não consegui), e como aplicar fallback pra quem ainda precisa suportar Firefox sem flag.

Se você chegou aqui querendo primeiro um panorama mais amplo de todas as formas de animar via scroll em CSS, vou publicar um artigo introdutório sobre isso em breve, e linko ele aqui assim que sair.

O problema que toda animação de scroll feita em JS carrega

Pega o exemplo mais comum: um card de produto que precisa ganhar opacidade conforme entra na viewport. A abordagem mais direta é escutar o evento scroll do navegador, calcular a posição do elemento a cada disparo e aplicar o estilo via JavaScript.

O evento scroll dispara em alta frequência, dezenas de vezes por segundo em scroll contínuo, e cada disparo roda seu callback no main thread, a mesma thread que resolve layout, calcula estilo e pinta a tela. A otimização mais comum aqui é aplicar debounce ou throttle pra reduzir a frequência de execução (já detalhei as duas técnicas neste outro artigo sobre debounce em JavaScript), mas isso administra o custo, não elimina ele: o trabalho continua rodando no main thread, só que com menos frequência.

Se naquele mesmo instante houver qualquer outro JavaScript pesado rodando, o cálculo da animação compete por CPU com o resto da aplicação. O resultado é a animação travando, atrasando ou pulando frames, o efeito mais rápido de destruir uma boa impressão de um site.

O caso de estudo publicado pelo Chrome for Developers em 2023 mostra isso na prática, não só na teoria. O post traz uma captura de tela chamada “Main thread comparison” comparando o uso de CPU das duas abordagens lado a lado: na versão com JavaScript clássico, o main thread fica saturado e a animação perde frames enquanto outro trabalho pesado roda; na versão com animation-timeline, o main thread segue livre e a animação continua fluida sob a mesma carga. O artigo também linka uma demo interativa da CyberAgent, onde fotos desaparecem conforme você rola a página, como exemplo real da técnica em produção.

De onde veio a ideia de amarrar animação ao scroll sem JS

Amarrar visual ao scroll nunca foi impossível, sempre deu pra fazer com JavaScript. O problema nunca foi capacidade, foi custo. Segundo a Smashing Magazine, a proposta original de uma especificação pra animações guiadas por scroll existe há cerca de uma década em relação à publicação do artigo (dezembro de 2024), o que situa a primeira proposta por volta de meados dos anos 2010, quase dez anos antes de qualquer navegador implementar a API de forma estável.

Não encontrei nenhuma fonte em português cobrindo especificamente esse histórico da proposta original da spec. Registro isso como lacuna de pesquisa em vez de forçar uma referência que não confirmei.

O motivador técnico, documentado pela própria especificação do CSS Working Group (CSSWG), é permitir que a progressão de uma animação seja guiada pela posição de rolagem “sem exigir execução de script a cada evento de scroll”. O objetivo desde o início foi tirar o cálculo do caminho crítico do JavaScript, não inventar um efeito visual novo.

As quatro abordagens que existiam antes do animation-timeline

  • Scroll listener puro com recálculo direto: a versão mais crua, e a que o caso de estudo do Chrome for Developers usa pra ilustrar o main thread saturado.
  • Debounce ou throttle combinados com requestAnimationFrame (a técnica que detalho aqui): reduz a frequência do callback, mas ainda depende de leituras de layout como getBoundingClientRect(), que podem forçar reflow se intercaladas com escritas de estilo.
  • IntersectionObserver puro: não dispara a cada pixel de scroll, só em mudanças de interseção. O limite é entregar estados discretos (entrou, saiu), não um progresso contínuo.
  • GSAP ScrollTrigger e AOS: abstraem o listener manual com API mais confortável, mas continuam dependendo do main thread e somam peso de JavaScript ao bundle.

Nenhuma das quatro elimina a dependência do main thread. Elas administram melhor ou pior o custo que ele impõe.

Como o animation-timeline resolve isso por dentro

A resposta do CSSWG foi tratar o scroll como uma timeline alternativa à DocumentTimeline padrão, aquela baseada em tempo que toda animação CSS usa por default. Em vez do progresso avançar em milissegundos, ele avança conforme a posição de rolagem de um contêiner.

Isso está formalizado na especificação “Scroll-driven Animations Module Level 1”, cujo texto normativo define que “a posição inicial de scroll representa 0% de progresso e a posição final representa 100%”. A spec está, em 2026, na fase de Editor’s Draft, e entre seus editores está Bramus Van Damme, engenheiro de developer relations do Chrome e um dos divulgadores mais ativos do recurso.

A propriedade central é animation-timeline, que aceita auto (o padrão baseado em tempo), none, uma timeline nomeada via --nome, ou timelines anônimas criadas com scroll() e view().

CSS
.progress-bar {
  animation: crescer linear;
  animation-timeline: scroll(root block);
}

@keyframes crescer {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

Pra ver isso rodando de verdade, não só no código: demo ao vivo no CodePen com essa barra de progresso presa ao scroll da página inteira.

Uma view() acompanha a posição de um elemento específico dentro da viewport, parecido com um IntersectionObserver, mas com progresso contínuo em vez de estados discretos:

CSS
.card {
  animation: aparecer linear;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

@keyframes aparecer {
  from { opacity: 0; transform: translateY(24px); }
  to { opacity: 1; transform: translateY(0); }
}

O mesmo vale pro view(): este outro demo no CodePen mostra o card ganhando opacidade e se deslocando conforme entra na viewport, sem nenhuma linha de JavaScript.

Um detalhe que quase ninguém menciona, mas confirmado na própria referência da MDN: animation-timeline é um valor “reset-only” dentro do shorthand animation. Se você declarar animation-timeline antes do shorthand animation no mesmo seletor, ele é sobrescrito silenciosamente. A ordem certa é sempre declarar animation-timeline depois do shorthand.

O que a spec garante e o que ela só permite

Esse é o ponto que mais gente erra ao explicar performance de scroll-driven animations, inclusive eu antes de ler o texto normativo com atenção. É comum ler que a técnica “roda inteiramente no compositor thread, o main thread nunca é envolvido”. Essa afirmação categórica não é o que a spec garante.

O texto diz que “agentes de usuário que suportam scroll assíncrono têm permissão, mas não obrigação, de amostrar esses efeitos de forma assíncrona também”. Rodar fora do main thread é uma otimização de implementação permitida, não uma garantia normativa.

Essa afirmação sempre me deixou com um pé atrás: “main thread nunca é envolvido” soa bom demais pra ser universalmente verdade. E é mesmo: o código-fonte do Chromium responsável pelo cálculo da timeline (scroll_timeline.cc, parte do Blink) mostra que a função ComputeTimelineState() depende de resolução de layout, incluindo posição atual e máxima de scroll, e essa resolução acontece no main thread. Isso sugere uma distinção mais sutil do que “tudo roda no compositor”: o valor de progresso da timeline parece ser calculado no main thread, enquanto a amostragem e interpolação do efeito visual em si (quando a animação anima só transform e opacity, que não exigem recálculo de layout) é que pode rodar no compositor thread. Não dá pra confirmar com certeza total sem também inspecionar o lado do compositor (cc/animation), então isso fica registrado como lacuna, não como fato fechado.

Um número que dá pra confirmar direto na spec: animar só transform e opacity evita forçar layout e paint a cada frame, ao contrário de animar left, top, width ou height, que obrigam recálculo de layout no main thread a cada frame, com ou sem scroll timeline envolvida. Não achei, nas fontes consultadas, um número exato de frame time em milissegundos comparando as duas abordagens. O caso de estudo do Chrome for Developers mostra a diferença via captura de tela do painel de performance, não via números no corpo do texto, então esse dado quantitativo também fica registrado como lacuna.

Fallback: e quando o navegador não suporta?

Essa é a pergunta que separa quem usa o recurso em produção de quem só faz demo. animation-timeline que não é reconhecido pelo navegador é simplesmente ignorado, sem erro, sem quebra. Na prática isso significa que o elemento fica no estado que você definiu fora da animação, então a regra de ouro é: defina o estado final (visível, no lugar certo) como padrão, e só depois some a animação por cima onde há suporte.

CSS
.card {
  opacity: 1; /* estado padrão, visível mesmo sem suporte */
}

@supports (animation-timeline: view()) {
  .card {
    opacity: 0;
    animation: aparecer linear;
    animation-timeline: view();
    animation-range: entry 0% cover 40%;
  }
}

Vale combinar esse @supports com prefers-reduced-motion, já que os dois cuidam de casos onde a animação não deve rodar por motivos diferentes (suporte técnico de um lado, acessibilidade do outro):

CSS
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .card {
      opacity: 0;
      animation: aparecer linear;
      animation-timeline: view();
    }
  }
}

Se sua aplicação depende de timelines vinculadas ou de outras funcionalidades mais avançadas da spec, existe um polyfill, mas ele reintroduz JavaScript no main thread e, segundo o desenvolvedor Josh Comeau, não cobre essas funcionalidades avançadas. Nesse caso específico, o polyfill acaba reproduzindo o mesmo problema de performance que a API nativa tenta resolver, o que faz o @supports com estado padrão visível ser geralmente a escolha mais simples e mais honesta.

Comparando as abordagens de frente

AbordagemRoda no main thread a cada frame?Progresso contínuo?Peso de bundleSuporte em 2026
animation-timeline nativoNão pro efeito visual (transform/opacity); o cálculo da timeline em si ainda depende de layoutSimZero JSChrome/Edge desde 115 (2023), Safari desde 26 (set/2025), Firefox atrás de flag até jun/2026
GSAP ScrollTriggerSimSimBiblioteca JS completaUniversal (JS puro)
IntersectionObserverNão a cada scroll, mas dispara callback em JS nas mudanças de interseçãoNão, só estados discretosAPI nativa, zero dependênciaUniversal há anos
Scroll listener + debounce/rAFSimSim, com custo administradoZero dependência externa, código próprio a manterUniversal

GSAP ScrollTrigger segue sendo a escolha certa quando o projeto já depende dele pra orquestrar timelines complexas entre vários elementos, ou quando suportar Firefox sem flag é obrigatório hoje. animation-timeline é a escolha certa pra efeitos isolados de opacidade e transformação em projetos que aceitam um @supports como fallback.

Voltando à dúvida: vale trocar?

Depende do que você tem hoje. Se já usa GSAP pra orquestrar animações complexas entre elementos, trocar agora não compensa o retrabalho. Se está escrevendo um scroll listener manual do zero, ou mantendo um debounce cuidadosamente ajustado só pra animar opacidade e transform, animation-timeline com um @supports de fallback resolve o mesmo problema com menos código e sem competir com o resto da sua aplicação pelo main thread.

A ressalva que a maior parte do conteúdo sobre o tema pula: “roda inteiramente no compositor thread” é uma simplificação. O cálculo do progresso da timeline em si ainda passa pelo main thread, segundo o próprio código-fonte do Chromium. A vitória real de performance está em não precisar mais de um callback JavaScript disparando a cada frame de scroll, não numa suposta imunidade total ao main thread.

Referências

Spec:

Documentação oficial:

Código-fonte:

Artigos de criadores e engenharia: