Ir para o conteúdo
React

Animações de Scroll com CSS Puro: animation-timeline e scroll() na Prática

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustracao 3D de painel de navegador em vidro fosco com barra de progresso cyan representando animacoes CSS de scroll
Ouça este artigo
0:00Animações de Scroll com CSS Puro: animation-timeline e scroll() na Prática--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

A barra de progresso de leitura que você vê em blogs como o Medium. O fade-in de seções conforme o usuário rola a página. O efeito parallax sutil em hero sections. Até pouco tempo, cada um desses efeitos exigia JavaScript: IntersectionObserver, scroll event listeners, ou bibliotecas como GSAP ScrollTrigger e Framer Motion.

Com animation-timeline e scroll(), o CSS resolve esses três cenários sem uma linha de JavaScript. Zero overhead de runtime, zero bundle size adicional, zero listeners no main thread.

Como funciona: a timeline deixa de ser tempo e passa a ser scroll

Uma animação CSS tradicional usa o tempo como eixo. Você define animation-duration: 1s e o browser interpola os keyframes ao longo de 1 segundo. Com animation-timeline, o eixo muda: em vez de milissegundos, o progresso da animação acompanha a posição de scroll de um container.

CSS
/* O progresso da animação vai de 0% a 100%
   conforme o scroll do elemento root (viewport) */
.progress-bar {
  animation: grow-width linear;
  animation-timeline: scroll();
}
 
@keyframes grow-width {
  from {
    width: 0%;
  }
  to {
    width: 100%;
  }
}

A função scroll() aceita dois argumentos opcionais: o scroller (qual elemento observar) e o eixo (block ou inline). Quando chamada sem argumentos, usa o nearest scroll ancestor no eixo block (vertical).

Progress bar de leitura: o exemplo canônico

Esse é o caso de uso que melhor demonstra o poder da feature. Em JavaScript, você precisaria de um scroll event listener, calcular scrollTop / (scrollHeight - clientHeight), e atualizar o width via style.width ou uma CSS custom property. Com animation-timeline, são 15 linhas de CSS:

CSS
.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  width: 0%;
  background: linear-gradient(90deg, #3b82f6, #8b5cf6);
  z-index: 9999;
 
  /* animation-duration é ignorado quando animation-timeline
     está ativo, mas o browser exige um valor válido */
  animation: fill-bar linear;
  animation-timeline: scroll(root block);
}
 
@keyframes fill-bar {
  to {
    width: 100%;
  }
}
HTML
<!-- O elemento precisa existir no DOM, mas não precisa de JS -->
<div class="reading-progress" aria-hidden="true"></div>
<main>
  <article>
    <!-- conteúdo longo aqui -->
  </article>
</main>

O argumento root em scroll(root block) garante que o scroller é o viewport, não um container intermediário com overflow. Isso é relevante quando a progress bar está dentro de um layout com scroll aninhado.

Fade-in de seções com view() e animation-range

A função scroll() observa o progresso total de scroll de um container. Para animar elementos conforme eles entram no viewport (o padrão "reveal on scroll"), a função certa é view(). Ela cria uma timeline baseada na visibilidade do próprio elemento dentro do scroll container:

CSS
.reveal-section {
  opacity: 0;
  transform: translateY(30px);
 
  animation: fade-in-up linear both;
  animation-timeline: view();
  /* A animação acontece entre o momento em que o elemento
     começa a entrar no viewport e quando 30% dele está visível.
     Sem animation-range, a animação se espalha por toda
     a passagem do elemento pelo viewport, ficando lenta demais. */
  animation-range: entry 0% entry 30%;
}
 
@keyframes fade-in-up {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
HTML
<section class="reveal-section">
  <h2>Arquitetura do projeto</h2>
  <p>Conteúdo que aparece com fade-in ao rolar.</p>
</section>
 
<section class="reveal-section">
  <h2>Stack de deploy</h2>
  <p>Mais conteúdo com o mesmo efeito.</p>
</section>

O animation-range é o que diferencia uma animação suave de uma animação arrastada. entry 0% é quando a borda inferior do elemento toca a borda inferior do viewport. entry 30% é quando 30% do elemento já está visível. Sem esse range, a animação se distribui por toda a travessia do elemento pelo viewport, e o efeito fica imperceptível.

Parallax sutil em hero sections

Parallax é um efeito onde camadas se movem em velocidades diferentes durante o scroll. A abordagem clássica com JavaScript calcula scrollY * factor e aplica translateY. Com animation-timeline, o cálculo desaparece:

CSS
.hero {
  position: relative;
  height: 100vh;
  overflow: hidden;
}
 
.hero-background {
  position: absolute;
  inset: -20% 0;
  background-image: url('/hero-bg.webp');
  background-size: cover;
  background-position: center;
 
  animation: parallax-shift linear;
  animation-timeline: scroll();
}
 
@keyframes parallax-shift {
  from {
    /* O inset negativo de -20% cria espaço extra
       para o background se mover sem revelar bordas */
    transform: translateY(-10%);
  }
  to {
    transform: translateY(10%);
  }
}
HTML
<section class="hero">
  <div class="hero-background"></div>
  <div class="hero-content">
    <h1>Título do site</h1>
  </div>
</section>

O truque do inset: -20% 0 resolve o problema mais comum de parallax: a imagem de fundo se mover e revelar espaço vazio nas bordas. Ao tornar o background 40% maior que o container (20% para cima, 20% para baixo), o translateY de -10% a 10% nunca ultrapassa a área visível.

Combinando múltiplas timelines no mesmo layout

Um cenário realista: progress bar no topo, seções com fade-in, e um header que encolhe ao rolar. Cada animação precisa de sua própria timeline:

CSS
/* Progress bar: acompanha o scroll total da página */
.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 0%;
  background: #3b82f6;
  z-index: 100;
  animation: fill linear;
  animation-timeline: scroll(root);
}
 
/* Header: encolhe nos primeiros 200px de scroll.
   Usa scroll() do root, mas com animation-range
   para limitar a faixa de atuação */
.site-header {
  position: sticky;
  top: 0;
  padding-block: 2rem;
  animation: shrink-header linear both;
  animation-timeline: scroll(root);
  /* 0px a 200px do scroll total */
  animation-range: 0px 200px;
}
 
/* Seções: cada uma anima conforme entra no viewport */
.content-section {
  opacity: 0;
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}
 
@keyframes fill {
  to { width: 100%; }
}
 
@keyframes shrink-header {
  to { padding-block: 0.5rem; }
}
 
@keyframes reveal {
  to { opacity: 1; }
}

O animation-range aceita valores em pixels quando a timeline é scroll(), e valores relativos (entry, exit, contain, cover) quando é view(). Essa distinção importa: usar entry 0% entry 40% com scroll() não faz sentido porque scroll() não tem conceito de "entrada no viewport".

O que NÃO fazer

Erro 1: esquecer o animation-duration

CSS
/* ERRADO: sem animation-duration, o browser ignora a animação */
.broken {
  animation-name: fade-in;
  animation-timeline: scroll();
}
 
/* CORRETO: animation-duration precisa existir, mesmo sendo ignorado.
   O shorthand "animation: fade-in linear" resolve porque
   o valor default de duration é 0s, mas o linear
   força o browser a processar a timeline. A forma explícita
   mais segura é declarar "auto" ou qualquer valor. */
.working {
  animation: fade-in linear both;
  animation-timeline: scroll();
}

O animation-duration é tecnicamente ignorado quando animation-timeline assume o controle, mas a spec exige que a animação seja válida. Sem animation-duration (ou sem o shorthand que implica um), o browser descarta a declaração inteira.

Erro 2: usar scroll() quando o scroller não tem overflow

CSS
/* ERRADO: .container não tem overflow, então scroll() 
   não encontra scroll ancestor e a animação não roda */
.container {
  height: auto; /* sem overflow */
}
 
.container .animated {
  animation: slide linear;
  animation-timeline: scroll();
}
 
/* CORRETO: especifique o scroller correto */
.container .animated {
  animation: slide linear;
  animation-timeline: scroll(root); /* usa o viewport */
}

Quando scroll() é chamado sem argumentos, o browser procura o nearest scroll ancestor. Se o elemento está dentro de um div sem overflow, o browser sobe a árvore até encontrar um. Se nenhum container tem scroll, a animação simplesmente não executa, sem erro no console.

Erro 3: animation-fill-mode ausente em view()

CSS
/* ERRADO: quando o elemento sai do range, a animação reseta.
   O fade-in acontece, mas ao continuar rolando, o elemento
   volta a opacity: 0 */
.bad-reveal {
  opacity: 0;
  animation: reveal linear;
  animation-timeline: view();
  animation-range: entry 0% entry 30%;
}
 
/* CORRETO: "both" ou "forwards" mantém o estado final */
.good-reveal {
  opacity: 0;
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 30%;
}

Suporte de browsers e estratégia de fallback

Browseranimation-timelinescroll()view()animation-range
Chrome 115+SimSimSimSim
Edge 115+SimSimSimSim
Firefox 110+FlagFlagFlagFlag
Safari 18+SimSimSimSim

Safari 18 (lançado em setembro de 2024) trouxe suporte completo. Firefox ainda exige a flag layout.css.scroll-driven-animations.enabled na versão estável, mas a implementação está funcional no Nightly.

A estratégia de fallback mais pragmática usa @supports:

CSS
/* Estado base: sem animação, conteúdo visível */
.reveal-section {
  opacity: 1;
  transform: none;
}
 
/* Só aplica a animação se o browser suporta */
@supports (animation-timeline: scroll()) {
  .reveal-section {
    opacity: 0;
    transform: translateY(30px);
    animation: fade-in-up linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 30%;
  }
}

Essa abordagem garante que o conteúdo nunca fica invisível em browsers sem suporte. O fallback é "sem animação", não "conteúdo quebrado".

Quando usar CSS scroll animations vs. JavaScript

CritérioCSS animation-timelineJavaScript (GSAP/Framer Motion)
PerformanceCompositor thread, 0 JS no main threadMain thread, pode causar jank
Complexidade da animaçãoKeyframes lineares, sem lógica condicionalSequências, callbacks, lógica arbitrária
Interação com estado da aplicaçãoNenhumaTotal (React state, Redux, etc.)
Suporte de browsers (2025)~90% global (sem Firefox estável)100%
Bundle size0 KBGSAP: ~25 KB, Framer Motion: ~35 KB
DebuggingDevTools > Animations panelConsole, breakpoints, logs

A regra prática: se a animação é decorativa e não depende de estado da aplicação, use CSS. Se a animação precisa reagir a dados, disparar callbacks, ou encadear sequências complexas com lógica condicional, JavaScript continua sendo a ferramenta certa.

Em projetos React com separação clara de concerns, as animações de scroll decorativas ficam no CSS e as animações de interação (modais, transições de rota, drag-and-drop) ficam no componente. Essa divisão evita que o CSS tente fazer o trabalho do JavaScript e vice-versa.

Para aplicações com Server Components no Next.js, scroll animations via CSS são ideais: não exigem "use client", não adicionam JavaScript ao bundle do componente, e funcionam em qualquer página renderizada no servidor. Se você está usando o App Router com layouts aninhados, a progress bar de leitura pode viver no layout raiz sem afetar o streaming de nenhuma rota.

Quando o cenário envolve deploy em edge, cada kilobyte de JavaScript importa. Substituir GSAP ScrollTrigger por CSS nativo remove 25 KB do bundle e elimina o tempo de parse/compile desse código no dispositivo do usuário.

Performance: por que o compositor faz diferença

Animações CSS vinculadas a transform e opacity rodam no compositor thread do browser, separado do main thread onde o JavaScript executa. Isso significa que mesmo que o main thread esteja ocupado (hidratação de componentes React, fetch de dados, processamento de mensageria assíncrona no service worker), as animações de scroll continuam suaves a 60fps.

Um scroll event listener em JavaScript, por outro lado, dispara no main thread. Se o handler faz element.style.transform = ..., o browser precisa recalcular layout, repintar e compor, tudo no mesmo frame. Em páginas complexas com muitos elementos animados, isso causa frame drops visíveis.

Scroll container específico com scroll() nomeado

Nem toda animação se vincula ao scroll do documento. Um carrossel horizontal, por exemplo, tem seu próprio container de scroll.

CSS
.horizontal-scroller {
  overflow-x: auto;
  display: flex;
  gap: 1rem;
  /* Registra este container como uma timeline nomeada */
  scroll-timeline-name: --scroller;
  scroll-timeline-axis: x;
}
 
.scroll-indicator {
  height: 3px;
  background: #10b981;
  transform-origin: left;
  transform: scaleX(0);
  animation: fill-bar linear;
  /* Referencia a timeline nomeada, não o scroll do documento */
  animation-timeline: --scroller;
}
 
@keyframes fill-bar {
  to {
    transform: scaleX(1);
  }
}
HTML
<div class="scroll-indicator"></div>
<div class="horizontal-scroller">
  <div class="card">Item 1</div>
  <div class="card">Item 2</div>
  <div class="card">Item 3</div>
  <div class="card">Item 4</div>
  <div class="card">Item 5</div>
</div>

A timeline nomeada (--scroller) permite que qualquer descendente ou elemento externo referencie aquele container. Isso resolve o problema de escopo: sem nomes, scroll() busca o ancestor scrollável mais próximo, que nem sempre é o que você quer.

Combinando com scroll-snap para carrosséis

Um padrão que funciona bem é combinar scroll-snap com animation-timeline para criar indicadores de progresso em carrosséis.

CSS
.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-timeline-name: --carousel;
  scroll-timeline-axis: x;
}
 
.carousel-slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
}
 
/* Indicador de dots que preenche conforme o scroll horizontal */
.carousel-progress {
  width: 200px;
  height: 4px;
  background: #e5e7eb;
  border-radius: 2px;
  position: relative;
}
 
.carousel-progress::after {
  content: '';
  position: absolute;
  inset: 0;
  background: #6366f1;
  border-radius: 2px;
  transform-origin: left;
  transform: scaleX(0);
  animation: fill-carousel linear;
  animation-timeline: --carousel;
}
 
@keyframes fill-carousel {
  to {
    transform: scaleX(1);
  }
}

Esse padrão elimina o JavaScript que normalmente sincroniza o estado do carrossel com indicadores visuais. A timeline nomeada (--carousel) garante que o indicador responde ao scroll daquele container específico, não ao scroll do documento. Em interfaces complexas como as que você encontra ao separar concerns no React, mover essa lógica para CSS simplifica o componente.

Integrando com React e Next.js

Em projetos React, scroll-driven animations funcionam sem nenhuma biblioteca adicional. O CSS é aplicado normalmente via CSS Modules, Tailwind ou styled-components.

TSX
// components/ReadingProgress.tsx
// Componente zero-JS: toda a lógica está no CSS
export function ReadingProgress() {
  return (
    <div
      className="reading-progress"
      aria-hidden="true"
      role="presentation"
    />
  );
}
CSS
/* components/ReadingProgress.module.css */
.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: var(--color-primary, #3b82f6);
  transform-origin: left;
  transform: scaleX(0);
  z-index: 9999;
}
 
@supports (animation-timeline: scroll()) {
  .reading-progress {
    animation: progress-scale 1s linear;
    animation-timeline: scroll(root block);
  }
}
 
@keyframes progress-scale {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

Se você usa React Server Components, esse componente pode ser um Server Component: não há estado, não há efeito, não há interatividade JavaScript. O CSS faz tudo. Isso significa zero bytes de JavaScript no bundle do cliente para essa feature.

Para projetos com App Router do Next.js 15, coloque o ReadingProgress no layout raiz e ele funciona em todas as páginas automaticamente.

FAQ

Posso usar animation-timeline com CSS Modules ou Tailwind?

Sim. animation-timeline é uma propriedade CSS padrão que funciona em qualquer contexto onde CSS funciona. Com Tailwind v4, você pode usar classes arbitrárias: [animation-timeline:scroll()]. Com CSS Modules, declare normalmente no arquivo .module.css. Não há conflito com scoping porque a propriedade não depende de seletores globais.

Como debugar animações de scroll no DevTools?

Chrome DevTools tem um painel dedicado: abra DevTools, vá em "Animations", e role a página. As animações vinculadas a scroll aparecem com uma timeline diferente das baseadas em tempo. Você pode pausar, scrub manualmente, e inspecionar os keyframes. No Firefox (com a flag ativada), o painel de animações também reconhece scroll-driven animations.

animation-timeline funciona com @media (prefers-reduced-motion)?

Sim, e você deve respeitar essa preferência. Envolva suas animações de scroll em @media (prefers-reduced-motion: no-preference) para garantir que usuários com sensibilidade a movimento não sejam afetados. O @supports para animation-timeline deve ficar dentro do media query, não o contrário.

Posso animar propriedades que causam layout (width, height, margin)?

Pode, mas não deveria. Animar width ou height força recalculation de layout a cada frame de scroll, anulando a vantagem de performance do compositor thread. Prefira transform: scaleX() para simular mudança de largura e transform: translateY() para simular mudança de posição. A progress bar do exemplo usa width porque é um elemento fixo sem siblings que sofram reflow, mas em listas ou grids, use transform.

Existe polyfill para browsers sem suporte?

O Google Chrome Labs mantém o scroll-timeline-polyfill (disponível no npm como scroll-timeline). Ele adiciona suporte via JavaScript, mas perde a vantagem de rodar no compositor thread. Se você precisa de suporte universal e performance, a combinação @supports com fallback para IntersectionObserver é mais previsível que um polyfill que emula comportamento nativo com limitações.

A posição deste blog

animation-timeline é a primeira feature de CSS em anos que elimina uma categoria inteira de JavaScript utilitário. Não é um atalho cosmético: é uma mudança de onde o trabalho acontece (compositor vs. main thread) e de quanto código o usuário precisa baixar (0 KB vs. 25-35 KB de biblioteca).

A adoção segura já é viável em 2025 para qualquer projeto que aceite "sem animação" como fallback em Firefox estável. Se o seu público é majoritariamente Chrome e Safari (o que cobre a maioria das aplicações web brasileiras), o suporte está acima de 90%. Use @supports, mantenha o conteúdo acessível sem a animação, e remova o IntersectionObserver que você estava usando só para fazer fade-in de seções.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.