Scroll-Driven Animations, corner-shape e CSS Nativo: Cobrindo as Lacunas que o Ecossistema JS Criou
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
/* 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;
}
/* 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
/* 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ã
---
Leia o artigo completo em [https://vivodecodigo.com.br/react/scroll-driven-animations-corner-shape-css-nativo](https://vivodecodigo.com.br/react/scroll-driven-animations-corner-shape-css-nativo)