Animaçõ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.
/* 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:
.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%;
}
}<!-- 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:
.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);
}
}<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:
.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%);
}
}<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:
/* 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
/* 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
/* 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()
/* 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
| Browser | animation-timeline | scroll() | view() | animation-range |
|---|---|---|---|---|
| Chrome 115+ | Sim | Sim | Sim | Sim |
| Edge 115+ | Sim | Sim | Sim | Sim |
| Firefox 110+ | Flag | Flag | Flag | Flag |
| Safari 18+ | Sim | Sim | Sim | Sim |
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:
/* 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ério | CSS animation-timeline | JavaScript (GSAP/Framer Motion) |
|---|---|---|
| Performance | Compositor thread, 0 JS no main thread | Main thread, pode causar jank |
| Complexidade da animação | Keyframes lineares, sem lógica condicional | Sequências, callbacks, lógica arbitrária |
| Interação com estado da aplicação | Nenhuma | Total (React state, Redux, etc.) |
| Suporte de browsers (2025) | ~90% global (sem Firefox estável) | 100% |
| Bundle size | 0 KB | GSAP: ~25 KB, Framer Motion: ~35 KB |
| Debugging | DevTools > Animations panel | Console, 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.
.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);
}
}<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.
.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.
// 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"
/>
);
}/* 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.

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.


