Ir para o conteúdo
React

Scroll-Driven Animations, corner-shape e CSS Nativo: Cobrindo as Lacunas que o Ecossistema JS Criou

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustracao 3D de painel de vidro fosco com scroll luminoso cyan e fragmentos JS se dissolvendo representando CSS nativo
Ouça este artigo
0:00Scroll-Driven Animations, corner-shape e CSS Nativo: Cobrindo as Lacunas que o Ecossistema JS Criou--:--

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

O dia em que removi 14.3KB de JavaScript e a animação ficou mais fluida

Em março de 2024, herdei o front-end de um e-commerce com 380k visitas/mês. A página de produto tinha um header que encolhia ao rolar, cards com cantos arredondados customizados via SVG clip-path gerado por JS, e uma timeline de scroll com Intersection Observer + requestAnimationFrame. Três bibliotecas (framer-motion, react-intersection-observer e um utilitário custom de 2.1KB) controlavam tudo isso.

O Lighthouse da página marcava 67 em performance no mobile. O TBT (Total Blocking Time) era 480ms, e o CLS batia 0.18 por causa de reflows durante a animação do header.

Substituí tudo por CSS puro: animation-timeline: scroll(), corner-shape (via progressive enhancement) e @keyframes nativos. O resultado: Lighthouse subiu para 89, TBT caiu para 120ms, CLS zerou. O bundle JS do componente de produto foi de 14.3KB gzipped para zero.

Esse post cobre as três lacunas que tornaram essa migração possível.

Scroll-Driven Animations: o que muda na prática

A spec Scroll-driven Animations (Level 1, Chromium 115+) introduz duas primitivas: scroll progress timelines e view progress timelines. A diferença importa.

Scroll progress timeline vincula a progressão de uma animação à posição de scroll de um contêiner. View progress timeline vincula a progressão ao quanto um elemento está visível dentro do viewport (ou de outro contêiner de scroll).

Antes dessa spec, a única forma de fazer isso era ouvir o evento scroll, calcular porcentagens manualmente e aplicar transforms via JS. O problema: o evento scroll dispara na main thread. Qualquer cálculo pesado nesse handler bloqueia o compositor, e a animação engasga.

Com animation-timeline, a animação roda inteiramente no compositor thread do navegador. Sem JavaScript. Sem reflow. Sem jank.

O header que encolhe ao rolar

CSS
/* header-shrink.css */
/* Por que @keyframes e não transition: porque precisamos de
   múltiplos estágios de transformação vinculados ao progresso
   do scroll, não a um estado binário hover/active */
@keyframes header-shrink {
  from {
    height: 80px;
    background-color: rgba(255, 255, 255, 0);
    backdrop-filter: blur(0px);
  }
  to {
    height: 48px;
    background-color: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(12px);
  }
}
 
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  animation: header-shrink linear both;
  /* scroll() sem argumentos usa o nearest scroll ancestor,
     que neste caso é o root scroller (html) */
  animation-timeline: scroll();
  /* O header deve completar a animação nos primeiros 200px
     de scroll, não ao longo da página inteira */
  animation-range: 0px 200px;
}
CSS
/* fallback para navegadores sem suporte */
/* Por que @supports e não feature detection via JS:
   porque o fallback é puramente visual, não funcional */
@supports not (animation-timeline: scroll()) {
  .site-header {
    height: 48px;
    background-color: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(12px);
    /* Sem animação: o header fica no estado final.
       Melhor que carregar um polyfill de 8KB */
  }
}

O animation-range: 0px 200px é o detalhe que a maioria dos tutoriais ignora. Sem ele, a animação se distribui por toda a altura scrollável do documento. Em uma página de produto com 4000px de conteúdo, o header levaria a página inteira para encolher, o que é inútil.

View timeline: revelando cards ao entrar no viewport

CSS
/* card-reveal.css */
@keyframes card-reveal {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
 
.product-card {
  animation: card-reveal linear both;
  animation-timeline: view();
  /* entry 0% = o elemento começa a entrar no viewport
     entry 100% = o elemento está totalmente dentro.
     Queremos que a animação complete quando 40% do card
     estiver visível, não quando 100% estiver */
  animation-range: entry 0% entry 40%;
}
CSS
/* Para listas longas, stagger sem JS */
/* Por que animation-delay com calc: porque cada card
   precisa de um offset diferente para criar o efeito
   cascata, e o CSS não tem index nativo em seletores */
.product-card:nth-child(1) { animation-delay: 0ms; }
.product-card:nth-child(2) { animation-delay: 60ms; }
.product-card:nth-child(3) { animation-delay: 120ms; }
.product-card:nth-child(4) { animation-delay: 180ms; }
 
/* Alternativa mais escalável com custom properties */
.product-grid {
  /* O JS só define o index uma vez na montagem,
     não roda em cada frame de scroll */
}
 
.product-card {
  animation-delay: calc(var(--card-index, 0) * 60ms);
}
TSX
// ProductGrid.tsx
// Único JS necessário: definir o index CSS uma vez
// Por que useRef + querySelectorAll em vez de state:
// porque não queremos re-render, só setar um atributo DOM
import { useEffect, useRef } from 'react';
 
interface ProductGridProps {
  children: React.ReactNode;
}
 
export function ProductGrid({ children }: ProductGridProps) {
  const gridRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const cards = gridRef.current?.querySelectorAll('.product-card');
    cards?.forEach((card, index) => {
      (card as HTMLElement).style.setProperty('--card-index', String(index));
    });
  }, [children]);
 
  return (
    <div ref={gridRef} className="product-grid">
      {children}
    </div>
  );
}

corner-shape: cantos além do border-radius

A propriedade corner-shape (CSS Backgrounds Level 4, ainda em draft) permite definir a forma do canto, não apenas o raio. Hoje, border-radius sempre produz um arco circular (ou elíptico). Com corner-shape, você escolhe entre round (padrão), scoop (côncavo), notch (corte reto em 45°), bevel (chanfro) e squircle (superelipse, o formato dos ícones do iOS).

O suporte em navegadores (meados de 2025) ainda é limitado. O Chrome Canary tem uma flag experimental. Mas o progressive enhancement funciona bem porque corner-shape sem suporte simplesmente mantém o border-radius circular.

CSS
/* corner-shape-examples.css */
 
/* Squircle: a curva contínua que o iOS usa.
   Diferente de border-radius porque não tem
   descontinuidade na transição curva→reta */
.app-icon {
  width: 64px;
  height: 64px;
  border-radius: 16px;
  corner-shape: squircle;
}
 
/* Scoop: cantos côncavos, útil para tags e badges */
.category-tag {
  padding: 4px 16px;
  border-radius: 8px;
  corner-shape: scoop;
  background-color: var(--color-accent);
}
 
/* Notch: corte diagonal, estilo industrial */
.alert-banner {
  padding: 16px 24px;
  border-radius: 12px;
  corner-shape: notch;
}
 
/* Fallback explícito para navegadores sem suporte */
@supports not (corner-shape: squircle) {
  .app-icon {
    /* Simula squircle com clip-path de superelipse.
       Não é idêntico, mas é a aproximação mais próxima */
    clip-path: path('M 16,0 C 4,0 0,4 0,16 L 0,48 C 0,60 4,64 16,64 L 48,64 C 60,64 64,60 64,48 L 64,16 C 64,4 60,0 48,0 Z');
  }
}

Antes de corner-shape, a alternativa para squircles era gerar SVG paths via JavaScript ou usar clip-path com valores hardcoded. Em um projeto de design system que mantive entre 2022 e 2023, tínhamos um utilitário TypeScript de 340 linhas que gerava clip-paths de superelipse para 5 tamanhos de ícone. Quando corner-shape: squircle estiver estável, essas 340 linhas viram uma linha de CSS.

Comparando abordagens de animação

CritérioJS (rAF + scroll event)Intersection Observer + CSS transitionsScroll-Driven Animations (CSS)
Thread de execuçãoMain threadMain thread (observer) + compositor (transition)Compositor thread
Bundle size adicional2-15KB (lib)~0.5KB (observer setup)0KB
Jank em dispositivos low-endFrequente acima de 30 elementosRaroInexistente
Granularidade de progressoTotal (0-100% contínuo)Binária (visível/não visível)Total (0-100% contínuo)
Suporte em navegadoresUniversalUniversalChromium 115+, Firefox 132+
Fallback necessárioNãoNãoSim, para Safari < 18.4

A coluna de suporte é o ponto de decisão. Se seu analytics mostra mais de 15% de tráfego em Safari abaixo de 18.4, use Intersection Observer como fallback e scroll-driven animations como progressive enhancement. Se o tráfego Safari legacy é menor que 5%, vá direto para CSS puro com um fallback estático via @supports.

O que NÃO fazer

Anti-pattern 1: Animar propriedades que causam layout

CSS
/* ❌ ERRADO: animar height e top causa reflow a cada frame */
@keyframes bad-header-shrink {
  from {
    height: 80px;
    padding: 20px;
    top: 0;
  }
  to {
    height: 48px;
    padding: 8px;
    top: -10px;
  }
}
 
.site-header-bad {
  animation: bad-header-shrink linear both;
  animation-timeline: scroll();
}

O fato de a animação rodar no compositor via animation-timeline: scroll() não significa que qualquer propriedade é segura. height, padding, top, width e margin ainda causam layout recalculation. O compositor consegue otimizar transform, opacity, filter, backdrop-filter e clip-path. O resto cai de volta para a main thread.

CSS
/* ✅ CORRETO: usar transform e opacity, que rodam no compositor */
@keyframes good-header-shrink {
  from {
    transform: scaleY(1);
    opacity: 1;
    backdrop-filter: blur(0px);
  }
  to {
    transform: scaleY(0.6);
    opacity: 0.98;
    backdrop-filter: blur(12px);
  }
}
 
.site-header-good {
  /* transform-origin no topo para que o scale
     encolha para baixo, não para o centro */
  transform-origin: top center;
  animation: good-header-shrink linear both;
  animation-timeline: scroll();
  animation-range: 0px 200px;
}

Anti-pattern 2: Usar scroll-driven animations para controlar lógica de negócio

Vi isso em um projeto de onboarding: alguém usou animation-timeline: view() para disparar uma mudança de estado via animationend. O problema é que animationend não dispara de forma previsível com scroll timelines, porque a animação não "termina" no sentido temporal. Ela chega a 100% de progresso, mas se o usuário rolar para cima, volta para 0%.

TSX
// ❌ ERRADO: tentar usar animationend para lógica de negócio
function OnboardingStep({ onComplete }: { onComplete: () => void }) {
  return (
    <div
      className="onboarding-card"
      onAnimationEnd={onComplete} // Nunca dispara de forma confiável
    >
      {/* ... */}
    </div>
  );
}
TSX
// ✅ CORRETO: usar Intersection Observer para lógica,
// CSS scroll-driven para visual
import { useEffect, useRef } from 'react';
 
function OnboardingStep({ onComplete }: { onComplete: () => void }) {
  const cardRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        // threshold 0.8 = 80% do card visível.
        // Por que 0.8 e não 1.0: porque em mobile,
        // a barra de endereço pode cobrir os últimos pixels
        if (entry.isIntersecting) {
          onComplete();
          observer.disconnect();
        }
      },
      { threshold: 0.8 }
    );
 
    if (cardRef.current) observer.observe(cardRef.current);
    return () => observer.disconnect();
  }, [onComplete]);
 
  return (
    <div ref={cardRef} className="onboarding-card">
      {/* A animação visual é CSS puro via view() */}
      {/* A lógica de "completou o passo" é JS via IO */}
    </div>
  );
}

A regra é simples: CSS controla pixels, JS controla estado. Misturar os dois dá problema.

Combinando tudo: progress bar de leitura de artigo

Um caso de uso que implementei em dois blogs (incluindo um projeto para uma editora com 1.2M pageviews/mês): barra de progresso de leitura que acompanha o scroll do <article>.

CSS
/* reading-progress.css */
@keyframes reading-progress {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}
 
.reading-progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background-color: var(--color-primary, #2563eb);
  transform-origin: left center;
  /* will-change desnecessário aqui: o compositor já
     otimiza transform em scroll-driven animations */
  animation: reading-progress linear both;
  /* Vincula ao scroll do root scroller */
  animation-timeline: scroll(root block);
  z-index: 9999;
}
 
/* Esconde a barra quando o artigo não está visível
   (ex: usuário está no topo, antes do artigo começar) */
.reading-progress-bar {
  animation-range: 0% 100%;
}
TSX
// ReadingProgress.tsx
// Componente zero-JS: é só um div com a classe CSS
export function ReadingProgress() {
  return (
    <div
      className="reading-progress-bar"
      role="progressbar"
      aria-label="Progresso de leitura"
      // aria-valuenow não é atualizável sem JS,
      // mas o papel semântico ainda ajuda screen readers
    />
  );
}

No blog da editora, a versão anterior usava um hook useScrollProgress com requestAnimationFrame. O componente React re-renderizava a cada frame para atualizar o style.transform. Em páginas longas (8000+ palavras), o profiler do Chrome mostrava 16ms+ de scripting por frame em um Moto G Power. Depois da migração para CSS puro, o scripting por frame caiu para 0ms. O tempo de composite subiu 0.3ms, o que é irrelevante.

Quando NÃO usar scroll-driven animations

Existem cenários onde JavaScript ainda é a resposta certa:

  1. Animações que dependem de dados dinâmicos. Se a animação precisa interpolar entre valores que vêm de uma API (ex: gráfico que anima ao rolar), CSS não tem acesso a esses valores em runtime.

  2. Orquestração complexa entre múltiplos elementos não-irmãos. animation-timeline: view() funciona por elemento. Se você precisa que o scroll de um painel controle a animação de um elemento em outro canto do DOM, o CSS exige que ambos compartilhem o mesmo scroll ancestor. Nem sempre isso é viável.

  3. Safari < 18.4 é mais de 20% do seu tráfego. O polyfill scroll-timeline-polyfill existe, mas adiciona ~6KB e roda na main thread, anulando o benefício principal. Nesse caso, Intersection Observer + CSS transitions é a melhor relação custo/benefício.

Se você quer se aprofundar em como organizar CSS em projetos maiores sem perder a sanidade, escrevi sobre metodologias de organização CSS e sobre custom properties como alternativa a pré-processadores. Para o lado de performance e infraestrutura de entrega, o post sobre caching no Next.js com ISR complementa bem, porque de nada adianta animação fluida se o HTML demora 3 segundos para chegar.

Minha posição sobre o futuro do CSS de animação

Nos últimos 5 anos, a comunidade front-end tratou CSS como cidadão de segunda classe. Tudo virou JavaScript: animações, layout condicional, temas, até espaçamento. Bibliotecas como framer-motion e GSAP são excelentes, mas se tornaram o padrão default em vez de ferramentas para casos que realmente precisam de orquestração programática.

Scroll-driven animations, corner-shape, @starting-style, anchor positioning, @scope e container queries estão mudando isso. O CSS de 2025 resolve 80% dos casos que antes exigiam JavaScript. Os outros 20% são orquestração complexa, animações baseadas em dados dinâmicos e interações com physics-based easing. Para esses, JavaScript continua sendo a ferramenta certa.

Eu adoto a regra: se a animação depende só de scroll, viewport ou estado CSS (hover, focus, checked), faça em CSS. Se depende de estado da aplicação (dados de API, input do usuário, lógica de negócio), faça em JS. Essa fronteira é clara e evita a armadilha de reescrever tudo em CSS "porque sim" ou manter tudo em JS "porque já funciona".

O mapa de decisões que publiquei sobre ferramentas do dev web moderno segue essa mesma filosofia: escolha a ferramenta pelo problema, não pela familiaridade. E se você está construindo APIs que alimentam essas interfaces, as lacunas críticas de JavaScript em APIs e npm são leitura complementar importante.

FAQ

Scroll-driven animations funcionam com overflow: auto em contêineres internos, não só no scroll da página?

Sim. O scroll() aceita argumentos: scroll(nearest block), scroll(root), scroll(self). Para um contêiner com overflow: auto, use animation-timeline: scroll(nearest) no elemento filho, e o contêiner mais próximo com overflow scrollável será usado como timeline. Testei isso em um painel de chat com scroll interno, funciona em Chromium e Firefox.

corner-shape já pode ser usado em produção?

Não como propriedade primária. Use como progressive enhancement: defina border-radius como fallback (funciona em tudo), e adicione corner-shape para navegadores que suportam. O visual degrada graciosamente para cantos circulares. Em produção, eu uso @supports (corner-shape: squircle) para aplicar estilos adicionais que só fazem sentido com a forma correta.

Como debugar scroll-driven animations no DevTools?

No Chrome 120+, a aba Animations do DevTools mostra timelines vinculadas a scroll. Você consegue ver o progresso da animação enquanto rola a página, pausar, e inspecionar o valor computado em qualquer ponto. No Firefox, o suporte no DevTools ainda é parcial. Minha recomendação: desenvolva e debugue no Chrome, teste no Firefox e Safari depois.

Scroll-driven animations afetam acessibilidade?

A animação em si não dispara prefers-reduced-motion automaticamente. Você precisa respeitar essa media query manualmente. Adicione @media (prefers-reduced-motion: reduce) { .element { animation: none; } } para qualquer animação vinculada a scroll. Isso não é opcional: é requisito WCAG 2.1 nível AA.

Posso combinar scroll-driven animations com View Transitions API?

São specs independentes que não interferem uma na outra. View Transitions controla a transição entre estados de página (navegação SPA ou MPA). Scroll-driven animations controlam animações vinculadas a scroll dentro de uma página. Usei ambas juntas em um projeto Next.js com App Router: View Transitions para navegação entre rotas, scroll-driven para animações dentro de cada página. Sem conflitos.

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.