Motion bom some e guia, motion ruim trava e espanta. Ou o site fica parado e não leva o olho até o botão, ou fica tão animado que o LCP estoura e o visitante sai antes do efeito terminar. Este guia mostra o meio que funciona em site de PME: 3 padrões GSAP com ScrollTrigger, 1 base global e as novidades do React 19.2, tudo com código e com o freio junto.
Uso esta stack em produção no meu portfólio, GSAP com ScrollTrigger mais Lenis e Tailwind 4, com First Load da home em torno de 118 kB (medição própria de lab, 2026).
Key Takeaways
- Use 3 padrões e pare por aí: reveal on-enter, parallax só decorativo com scrub e pin com scrub para narrativa curta.
- Padronize 1 ease global, stagger de 0,06 a 0,09s e clip-mask em títulos; anime só transform e opacity para não forçar layout (Google web.dev).
- React 19.2 adicionou Activity e
useEffectEvent. View Transitions depende da versão do React e da integração do framework; não é uma API estável do React 19.2.- Entregue versão estática real em
prefers-reduced-motione desligue pin no mobile com viewport dinâmico do iOS.
Os 3 padrões que uso em site que vende
Os três padrões que cobrem 90% das necessidades são reveal on-enter para seções, parallax só decorativo com scrub para fundo e pin com scrub para narrativa curta de 2 a 4 beats que termina em CTA. Reveal apresenta, parallax dá profundidade sem cobrar leitura, pin segura a atenção na história do produto. Qualquer coisa fora disso precisa justificar o custo antes de entrar no layout.
Gancho cliente: cada padrão termina com o botão visível. Animação que não aproxima do contato é enfeite pago com performance.
// 1. Reveal on-enter: aparece uma vez, sem replay irritante
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export function initReveal() {
gsap.utils.toArray<HTMLElement>('[data-reveal]').forEach((el) => {
gsap.fromTo(
el,
{ y: 28, opacity: 0 },
{
y: 0,
opacity: 1,
duration: 0.7,
ease: 'power3.out',
scrollTrigger: { trigger: el, start: 'top 85%', once: true },
}
);
});
}
// 2. Parallax só decorativo: fundo mexe, texto fica parado e legível
export function initParallax() {
gsap.utils.toArray<HTMLElement>('[data-parallax]').forEach((el) => {
gsap.to(el, {
yPercent: -12,
ease: 'none',
scrollTrigger: { trigger: el.parentElement, start: 'top bottom', end: 'bottom top', scrub: true },
});
});
}
// 3. Pin com scrub: narrativa curta presa na tela, com fim claro
export function initPinNarrative() {
const track = document.querySelector('[data-pin-track]');
if (!track || window.matchMedia('(max-width: 768px)').matches) return;
gsap.to(track, {
xPercent: -50,
ease: 'none',
scrollTrigger: { trigger: '[data-pin]', start: 'top top', end: '+=200%', pin: true, scrub: 1 },
});
}
Quando não usar: parallax em texto corrido nunca, pin em página com pressa de contato nunca, e os três juntos na mesma dobra nunca. Um protagonista por tela.
Base global: 1 ease, stagger curto e clip-mask
Site com cara profissional não tem 5 easings diferentes, tem um. Defina 1 ease global (power3.out resolve quase tudo), stagger de 0,06 a 0,09s entre itens de lista ou cards para leitura em cascata sem arrastar (padrão de projeto do autor, 2026), títulos com clip-mask que revelam de baixo para cima e regra férrea de animar só transform e opacity, que não forçam recálculo de layout e protegem CLS e INP (Google web.dev, guia de animações de alta performance).
Stagger curto em cascata: ordem sem espera.
// Base global: 1 ease, stagger e máscara de título
export const EASE_GLOBAL = 'power3.out';
export const STAGGER = 0.08;
export function initStagger() {
gsap.utils.toArray<HTMLElement>('[data-stagger]').forEach((group) => {
gsap.fromTo(
group.children,
{ y: 24, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.6, ease: EASE_GLOBAL, stagger: STAGGER,
scrollTrigger: { trigger: group, start: 'top 85%', once: true } }
);
});
}
/* Clip-mask em títulos: revela sem empurrar layout */
.mask-titulo { overflow: hidden; display: block; }
.mask-titulo > span { display: block; transform: translateY(110%); }
Para a base que sustenta isso sem travar, veja a migração Next.js 16.
Activity e useEffectEvent no React 19.2; View Transitions em versões compatíveis
O lançamento oficial do React 19.2 inclui Activity, que preserva estado ao ocultar conteúdo, e useEffectEvent, para separar eventos da sincronização de efeitos. View Transitions não foi lançada como API estável nessa versão. O exemplo abaixo segue a documentação atual de ViewTransition: confira a versão suportada pelo seu framework antes de usá-lo. A atualização de estado precisa ocorrer em uma Transition para ativar a animação; trocar uma prop de forma síncrona não basta.
// Requer versão do React compatível com ViewTransition.
// O componente pai deve atualizar ativa dentro de startTransition.
import { ViewTransition } from 'react';
export function Abas({ ativa }: { ativa: string }) {
return (
<ViewTransition>
<section key={ativa}>
<h2>{ativa}</h2>
</section>
</ViewTransition>
);
}
// Activity: esconde painel mantendo estado do formulário
import { Activity } from 'react';
export function Painel({ visivel }: { visivel: boolean }) {
return (
<Activity mode={visivel ? 'visible' : 'hidden'}>
<form>...</form>
</Activity>
);
}
// useEffectEvent: handler estável dentro de efeito com scroll
import { useEffect, useEffectEvent } from 'react';
export function useLogScroll(ativo: boolean) {
const onScroll = useEffectEvent(() => {
// lê estado atual sem reinscrever o listener
if (ativo) console.log(window.scrollY);
});
useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
}
O que aprendi dissecando a Plantica
A Plantica, referência de site narrativo com scroll coreografado, funciona por três decisões copiáveis para orçamento de PME: camadas defasadas que se movem em velocidades diferentes para dar profundidade com poucos assets, 1 ideia por beat de scroll para ninguém ler parágrafo em movimento, e 1 truque repetido que vira identidade em vez de 10 efeitos aleatórios. Copie a estrutura, não o asset: duas camadas e um ritmo já mudam a percepção de qualidade.

Aplicação direta: hero com fundo em parallax lento mais título em reveal, seção de benefício com stagger de cards, faixa narrativa com pin curto que termina no botão de WhatsApp.
Performance e acesso: motion que não exclui nem trava
Toda animação precisa de versão estática real para quem prefere movimento reduzido, não só transição desligada com conteúdo invisível, conforme a media query prefers-reduced-motion (W3C) e o guia de acessibilidade do web.dev. No mobile, desligue pin por padrão: viewport dinâmico do iOS muda a altura com a barra do Safari e pin calculado errado gera salto e CLS. E saiba o preço de cada padrão antes de vender: reveal custa pouco, parallax moderado, pin com scrub custa caro e pede narrativa curta.
Custo relativo dos padrões: reveal primeiro, pin por último.
// Freio real: reduced motion vira estático, mobile sem pin
export function motionLiberado(): boolean {
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
export function initMotion() {
if (!motionLiberado()) return; // conteúdo já visível no HTML/CSS base
initReveal();
initStagger();
initParallax();
initPinNarrative(); // interna já ignora mobile
}

4 erros que fazem motion bonito derrubar conversão
Os quatro erros que mais vejo em site bonito que não vende: parallax em texto corrido, tudo entrando com o mesmo fade, pin longo no mobile e animar width, top ou left, que força layout a cada frame e piora CLS e INP.
Para o plano completo de site que converte, volte ao guia pilar site que vende todo dia. E proteja as métricas que o motion não pode quebrar com o checklist Core Web Vitals.
Perguntas frequentes
GSAP pesa muito no bundle?
O núcleo mais ScrollTrigger soma dezenas de kB gzip, valor que se paga quando o scroll é o vendedor, como em página de serviço ou case. Se o site só precisa de fade de entrada e transição de aba, View Transitions do React mais CSS resolvem sem a lib. No meu portfólio, GSAP entra porque o scroll conta a história até o WhatsApp, com First Load da home em torno de 118 kB (medição própria de lab, 2026).
View Transitions substituem o GSAP?
Não, elas dividem o trabalho. View Transitions cuidam de troca de estado e rota com pouco código, GSAP com ScrollTrigger cuida de scroll coreografado com scrub, pin e stagger fino. Use React para transição de UI e GSAP para narrativa de scroll, com transform e opacity nos dois para proteger as métricas.
Lenis conflita com ScrollTrigger?
Não, desde que você conecte os dois: Lenis assume o smooth scroll e avisa o ScrollTrigger a cada frame para recalcular posições. Sem essa ponte, o trigger dispara fora de hora e o pin erra a medida. Com a ponte ligada e scrub: 1 em vez de scrub seco, o movimento fica macio sem dessincronizar o marcador.
Conclusão
- 3 padrões bastam: reveal on-enter, parallax decorativo com scrub e pin com scrub em narrativa curta.
- Base global: 1 ease, stagger 0,06 a 0,09s, clip-mask em títulos, só transform e opacity.
- React 19.2 trouxe Activity e
useEffectEvent; use View Transitions apenas em versões compatíveis e com atualizações em Transition. - Freio obrigatório: versão estática real em reduced motion e pin desligado no mobile.
