Scroll-driven animations com CSS puro: sem biblioteca, sem JavaScript

1. Introdução às Scroll-driven Animations

Scroll-driven animations representam uma mudança de paradigma no desenvolvimento web. Pela primeira vez, podemos criar animações diretamente vinculadas ao scroll do usuário usando apenas CSS — sem uma única linha de JavaScript, sem Intersection Observer, sem bibliotecas como ScrollMagic ou GSAP.

O que torna essas animações revolucionárias é a capacidade de sincronizar automaticamente a progressão de uma animação com a posição do scroll. Antes, isso exigia cálculos constantes de posição, listeners de eventos e manipulação manual do DOM. Agora, o navegador faz todo o trabalho pesado de forma nativa e otimizada.

O suporte atual é promissor: Chrome 115+, Edge 115+ e Opera já implementam completamente a especificação. Firefox está em desenvolvimento ativo, e Safari sinalizou interesse. Para produção, é essencial considerar fallbacks, mas o futuro é claro.

2. Conceitos Fundamentais: Scroll Timeline vs View Timeline

Existem dois tipos principais de timelines em scroll-driven animations:

Scroll Timeline (scroll-timeline): A animação progride conforme o scroll de um contêiner específico. O início da animação corresponde ao scroll no topo, e o fim corresponde ao scroll no final do contêiner.

View Timeline (view-timeline): A animação progride conforme um elemento específico se move através da viewport. A animação começa quando o elemento aparece na tela e termina quando ele sai completamente.

As funções scroll() e view() são atalhos práticos para usar essas timelines diretamente na propriedade animation-timeline:

/* Scroll Timeline: vinculada ao scroll do documento */
.elemento {
  animation-timeline: scroll();
}

/* View Timeline: vinculada à visibilidade do próprio elemento */
.elemento {
  animation-timeline: view();
}

3. Criando Animações com animation-timeline

A sintaxe básica é surpreendentemente simples. Vamos criar uma barra de progresso de leitura sem JavaScript:

<!DOCTYPE html>
<html>
<head>
<style>
  body {
    margin: 0;
    height: 200vh;
    font-family: system-ui, sans-serif;
  }

  .progress-bar {
    position: fixed;
    top: 0;
    left: 0;
    height: 4px;
    background: linear-gradient(90deg, #667eea, #764ba2);
    width: 0%;
    animation: fillProgress linear;
    animation-timeline: scroll();
  }

  @keyframes fillProgress {
    from { width: 0%; }
    to { width: 100%; }
  }

  .content {
    max-width: 800px;
    margin: 60px auto;
    padding: 20px;
    line-height: 1.8;
  }
</style>
</head>
<body>
  <div class="progress-bar"></div>
  <div class="content">
    <h1>Artigo extenso</h1>
    <p>Conteúdo longo que ocupa bastante espaço vertical...</p>
    <!-- Repetir parágrafos para gerar scroll -->
    <p style="margin-top: 200vh">Fim do artigo</p>
  </div>
</body>
</html>

A mágica está em animation-timeline: scroll() — sem eventos, sem cálculos, sem JavaScript.

4. Trabalhando com Ranges e Keyframes Específicos

Os ranges permitem controlar exatamente quando a animação deve começar e terminar em relação à viewport. Os principais valores são:

  • entry: do momento em que o elemento começa a entrar até estar completamente visível
  • exit: do momento em que o elemento começa a sair até estar completamente fora
  • cover: do início da entrada até o fim da saída (ciclo completo)
  • contain: quando o elemento está completamente dentro da viewport

Exemplo de fade-in e fade-out conforme o elemento cruza a tela:

.fade-card {
  animation: fadeInOut linear;
  animation-timeline: view();
  animation-range: entry 0% exit 100%;
}

@keyframes fadeInOut {
  entry 0% {
    opacity: 0;
    transform: translateY(50px);
  }
  entry 100% {
    opacity: 1;
    transform: translateY(0);
  }
  exit 0% {
    opacity: 1;
    transform: translateY(0);
  }
  exit 100% {
    opacity: 0;
    transform: translateY(-50px);
  }
}

5. Animações com Múltiplos Elementos e Efeitos de Parallax

Para criar um efeito parallax nativo, combinamos animation-timeline com transform em diferentes velocidades:

.parallax-layer {
  position: fixed;
  width: 100%;
  height: 100vh;
  animation: parallaxScroll linear;
  animation-timeline: scroll();
}

.layer-back {
  animation-range: 0% 200%;
  z-index: 1;
}

.layer-front {
  animation-range: 0% 100%;
  z-index: 2;
}

@keyframes parallaxScroll {
  from { transform: translateY(0); }
  to { transform: translateY(-50%); }
}

Para escalar em diferentes telas, use unidades relativas e evite valores fixos em pixels nos keyframes.

6. Controlando Direção, Velocidade e Repetição

Scroll-driven animations aceitam as mesmas propriedades de animação tradicionais:

.elemento {
  animation: slideIn linear;
  animation-timeline: scroll();
  animation-direction: alternate;
  animation-fill-mode: both;
  animation-iteration-count: 1;
}

A velocidade relativa ao scroll é controlada indiretamente pelo animation-range. Um range maior significa que a animação se estende por mais scroll, parecendo mais lenta.

Para animações mistas (scroll-driven + time-driven), use timelines diferentes:

.elemento {
  animation: 
    pulse 2s ease-in-out infinite,     /* time-driven */
    slideIn linear scroll();           /* scroll-driven */
}

7. Limitações, Fallbacks e Boas Práticas

Nem todos os navegadores suportam scroll-driven animations. Use @supports para fallbacks:

@supports (animation-timeline: scroll()) {
  .animated-element {
    animation: fadeIn linear;
    animation-timeline: view();
  }
}

@supports not (animation-timeline: scroll()) {
  .animated-element {
    opacity: 1;
    transform: none;
  }
}

Respeite prefers-reduced-motion:

@media (prefers-reduced-motion: reduce) {
  .animated-element {
    animation: none !important;
  }
}

Performance: scroll-driven animations são executadas no compositor do navegador, fora da thread principal. São inerentemente mais eficientes que alternativas em JavaScript, especialmente em dispositivos móveis.

Use scroll-driven animations para: barras de progresso, fade-in de elementos, parallax sutil, indicadores de seção. Evite para: animações complexas com múltiplos estágios ou que exigem lógica condicional.

8. Exemplo Completo: Landing Page com Scroll-driven Animations

<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    font-family: system-ui, sans-serif;
    background: #0f0f23;
    color: #e0e0e0;
    overflow-x: hidden;
  }

  /* Barra de progresso */
  .progress {
    position: fixed;
    top: 0;
    left: 0;
    height: 3px;
    background: linear-gradient(90deg, #ff6b6b, #ffd93d);
    z-index: 100;
    animation: fillProgress linear;
    animation-timeline: scroll();
  }

  @keyframes fillProgress {
    from { width: 0%; }
    to { width: 100%; }
  }

  /* Hero com parallax */
  .hero {
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    overflow: hidden;
  }

  .hero-bg {
    position: absolute;
    width: 100%;
    height: 120%;
    background: radial-gradient(circle at 50% 50%, #1a1a4e, #0f0f23);
    animation: parallaxBg linear;
    animation-timeline: scroll();
    animation-range: 0% 100%;
  }

  @keyframes parallaxBg {
    from { transform: translateY(0); }
    to { transform: translateY(-20%); }
  }

  .hero-content {
    position: relative;
    z-index: 1;
    text-align: center;
    animation: heroFade linear;
    animation-timeline: scroll();
    animation-range: 0% 50%;
  }

  @keyframes heroFade {
    from { opacity: 1; transform: scale(1); }
    to { opacity: 0; transform: scale(0.8); }
  }

  /* Seções com view timeline */
  section {
    min-height: 60vh;
    padding: 80px 20px;
    max-width: 800px;
    margin: 0 auto;
  }

  .card {
    background: rgba(255,255,255,0.05);
    border-radius: 16px;
    padding: 40px;
    margin: 40px 0;
    border: 1px solid rgba(255,255,255,0.1);
    animation: cardReveal linear;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }

  @keyframes cardReveal {
    from {
      opacity: 0;
      transform: translateY(60px) scale(0.95);
    }
    to {
      opacity: 1;
      transform: translateY(0) scale(1);
    }
  }

  /* Imagem com fade-in */
  .image-wrapper {
    margin: 40px 0;
    overflow: hidden;
    border-radius: 12px;
  }

  .image-wrapper img {
    width: 100%;
    height: auto;
    display: block;
    animation: imageZoom linear;
    animation-timeline: view();
    animation-range: entry 0% exit 100%;
  }

  @keyframes imageZoom {
    entry 0% { transform: scale(1.1); opacity: 0; }
    entry 100% { transform: scale(1); opacity: 1; }
    exit 0% { transform: scale(1); opacity: 1; }
    exit 100% { transform: scale(0.9); opacity: 0; }
  }

  /* Footer */
  footer {
    text-align: center;
    padding: 60px 20px;
    opacity: 0.6;
    animation: footerFade linear;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }

  @keyframes footerFade {
    from { opacity: 0; transform: translateY(30px); }
    to { opacity: 0.6; transform: translateY(0); }
  }

  /* Fallback para navegadores sem suporte */
  @supports not (animation-timeline: scroll()) {
    .hero-content { opacity: 1; }
    .card { opacity: 1; transform: none; }
    .image-wrapper img { opacity: 1; transform: none; }
    footer { opacity: 0.6; }
  }

  /* Acessibilidade */
  @media (prefers-reduced-motion: reduce) {
    .progress, .hero-bg, .hero-content,
    .card, .image-wrapper img, footer {
      animation: none !important;
    }
  }

  h1 { font-size: 3.5rem; margin-bottom: 20px; }
  h2 { font-size: 2rem; margin-bottom: 20px; color: #ffd93d; }
  p { line-height: 1.8; font-size: 1.1rem; }
</style>
</head>
<body>
  <div class="progress"></div>

  <section class="hero">
    <div class="hero-bg"></div>
    <div class="hero-content">
      <h1>Scroll-driven Animations</h1>
      <p>CSS puro, zero JavaScript</p>
    </div>
  </section>

  <section>
    <h2>Como funciona</h2>
    <div class="card">
      <p>Scroll-driven animations vinculam a progressão da animação diretamente ao scroll do usuário. Cada card aparece suavemente ao entrar na viewport.</p>
    </div>
    <div class="card">
      <p>Usando <code>animation-timeline: view()</code>, o navegador calcula automaticamente quando cada elemento deve começar e terminar sua animação.</p>
    </div>
    <div class="card">
      <p>Não há Intersection Observer, não há event listeners, não há JavaScript. Tudo é nativo e otimizado pelo navegador.</p>
    </div>
  </section>

  <section>
    <h2>Efeitos visuais</h2>
    <div class="image-wrapper">
      <img src="https://picsum.photos/800/400?random=1" alt="Exemplo visual">
    </div>
    <div class="card">
      <p>Imagens podem ter zoom suave, fade e parallax — tudo controlado por ranges como <code>entry</code> e <code>exit</code>.</p>
    </div>
  </section>

  <footer>
    <p>© 2025 — Exemplo didático de scroll-driven animations com CSS puro</p>
  </footer>
</body>
</html>

Este exemplo completo demonstra barra de progresso, parallax no hero, fade-in de cards, zoom em imagens e footer animado — tudo sem JavaScript. A página é responsiva, acessível e inclui fallbacks para navegadores sem suporte.

Referências