Pular para o conteúdo
Zumkai

Motions premium em Next.js: os 16 padrões do site da Beetogreen, passo a passo

Estados por seção, GSAP mínimo e CSS máximo: os 16 padrões de motion do site da Beetogreen em Next.js, com replay por chave e o prompt pronto de cada um.

  • motion
  • gsap
Home do site da Beetogreen: título “Mobility that makes a difference” em verde-limão sobre vídeo de escritório, com formas orgânicas verde-escuras recortando a cena
Sumário
  1. A filosofia: GSAP mínimo, CSS máximo, estados por seção
  2. A stack e o papel de cada peça
  3. O mapa dos 16 motions
  4. Passo 1 — A fundação: Lenis, o hook de entrada e o split no render
  5. Passo 2 — O sistema nervoso tipográfico: reveal de palavras por seção (Motion 3)
  6. Passo 3 — Vitórias rápidas em CSS puro: underline que viaja e marquee com máscara
  7. Passo 4 — A moldura de entrada: preloader com Lottie e a pincelada de rota
  8. Passo 5 — A casca de navegação: header camaleão e nav mobile em cascata
  9. Passo 6 — O padrão em profundidade: acordeão com palavras em cascata (Motion 13)
  10. Passo 7 — O par de autoplay: depoimentos com anel e citação que se reescreve
  11. Passo 8 — A seção mais viva: processo autoplay com cortinas e cor viva (Motion 8)
  12. Passo 9 — O único pin do site: cards com tombamento 3D (Motion 9)
  13. Passo 10 — Interações de conteúdo: abas com saída limpa e a calculadora viva
  14. Passo 11 — O fechamento: formas de marca e rodapé parallax
  15. As 15 armadilhas que vão te morder (leia antes de codar)
  16. Por onde começar hoje

Quarto estudo de engenharia reversa da série, e mais uma resposta diferente para a mesma pergunta. A Plantica animava tudo com variáveis contínuas de CSS; a ANAI dividia o trabalho em três camadas; o Studio Modular empacotava cada efeito num módulo com gates. O site desta vez tem a filosofia mais enxuta de todas — e a que melhor conversa com o jeito React de pensar.

O auditado é a Beetogreen (beetogreen.com), líder francesa em mobilidade corporativa por bicicleta — "Mobility that makes a difference", mais de 500 mil funcionários elegíveis, 500+ empresas atendidas. Visualmente: verde-limão elétrico sobre verde-floresta, formas orgânicas gigantes recortando o vídeo do hero, e uma transição de rota assinada com uma pincelada caligráfica. Por trás, uma plataforma Nuxt/Vue + CMS headless onde o JavaScript de animação se resume a meia dúzia de intervenções cirúrgicas — todo o resto é CSS disparado por classes de estado. Os 16 padrões abaixo foram catalogados auditando esse site e traduzidos para o ecossistema Next.js; textos, cores e nomes são placeholders. O que você leva é o padrão, a técnica e um prompt pronto para colar no seu agente de IA.

Como sempre, a ordem dos passos é a ordem de construção.

A filosofia: GSAP mínimo, CSS máximo, estados por seção

O contrato de animação da Beetogreen cabe numa frase: o JS decide "em que estado a seção está"; o CSS decide "como cada estado se parece". Na prática:

  • O GSAP aparece em pouquíssimos lugares: preloader, transição de rota e dois scrubs (os cards 3D e o rodapé). Só.
  • Todo o resto é CSS disparado por classes de estado no nível da seção: --entered, --active, --past, --open. Um único hook de IntersectionObserver alterna a entrada; a lógica de autoplay alterna o resto.
  • O trio --active/--past/--future é o que permite trocas de conteúdo em fluxo contínuo: as palavras do conteúdo velho sobem e saem por cima, as do novo entram por baixo — mesma fenda, sem brigas.
  • As cores das seções vêm do CMS e as animações as respeitam: o valor mora no style inline, a transition mora no CSS. A seção muda de paleta sem uma linha de tween.
  • E o padrão mais React-idiomático da série: replay por chave. Keyframes CSS re-executam quando o nó remonta (key={n}) — é assim que citações se reescrevem, anéis de autoplay reiniciam e formas de marca re-entram. A chave é o botão de play.

A stack e o papel de cada peça

BibliotecaPapel no projetoPor quê
LenisSmooth scroll com easing expo custom + scrollTo programáticoO site auditado integra Lenis ao ScrollTrigger e expõe isScrolling num store global
GSAP 3.13+ e @gsap/reactPreloader, transição de rota "pincelada", dois scrubs (cards 3D e rodapé)GSAP é 100% gratuito, incluindo todos os plugins, desde a aquisição pela Webflow. Aqui é usado de forma minimalista
ScrollTrigger (plugin GSAP)Pin dos cards de estatísticas e parallax do rodapé (com clamp())Únicos dois usos de scroll-drive do site inteiro
Lottie (@lottiefiles/dotlottie-react)Logo animado do preloaderA duração do próprio Lottie temporiza a cortina de saída
CSS transitions, keyframes e classes de estadoTodo o resto: reveals de palavra, cortinas de imagem, acordeão, marquee, anéis de progressoRoda no compositor; os estados são a única API

Nenhum plugin de carrossel: o slider de depoimentos é próprio (troca por índice + replay de keyframes por remontagem de chave — Motion 11).

O mapa dos 16 motions

#MotionUso típicoGatilho
1Preloader com logo animado + cortinaPrimeira cargaLoad
2Transição de rota "pincelada"Toda navegaçãoNavegação
3Reveal de palavras por seçãoTítulos e parágrafos, o site inteiroInview
4Underline que se apaga + micro-linksLinks CTA de textoHover
5Marquee parametrizado com máscaraFaixas de logos/palavrasLoop
6Header adaptativo por tema + véuNavegação globalScroll/Hover
7Nav mobile com cascata de tagsMenu mobileClique
8Processo autoplay com cortinas e cor vivaSeção "como funciona"Inview/Auto/Clique
9Cards pinados com tombamento 3DEstatísticas/manifestoScroll
10Depoimentos com anel de contagemProva socialAuto/Clique
11Citação que se reescreve palavra a palavraDentro do Motion 10Troca de slide
12Cards em abas com transição de saída/entradaVantagens/planosClique
13Acordeão com palavras em cascataFAQClique
14Calculadora interativa com resultados vivosSimulador/orçamentoInput
15Formas decorativas de marca por seçãoFundos e apoios visuaisEstado
16Rodapé parallax + voltar ao topo suaveRodapé globalScroll/Clique

Reveals genéricos de seção são sempre o Motion 3: não existe outro sistema de entrada no site auditado — a consistência vem daí.

Passo 1 — A fundação: Lenis, o hook de entrada e o split no render

A infra deste guia tem duas peças que merecem atenção especial. O useHasEntered — um IntersectionObserver once que devolve um boolean — é o único gatilho de reveal do site inteiro. E o splitWords quebra os textos em spans durante o render (util puro, server-friendly): o markup já chega ao browser com as fendas prontas, sem medição de DOM, sem flash, sem mismatch de hidratação.

txt
Em um projeto Next.js 14+ com App Router, TypeScript e Tailwind, configure a
infraestrutura de animação:

1. Instale: gsap @gsap/react lenis @lottiefiles/dotlottie-react
2. Crie um componente client "SmoothScrollProvider" que:
   - Instancia Lenis com o easing do site auditado:
       new Lenis({
         easing: (t) => Math.min(1, 1.001 - Math.pow(2, -8 * t)),
         orientation: 'vertical',
         smoothWheel: true
       })
   - Sincroniza com ScrollTrigger usando função nomeada:
       const update = (time: number) => lenis.raf(time * 1000);
       lenis.on('scroll', ScrollTrigger.update);
       gsap.ticker.add(update);
       gsap.ticker.lagSmoothing(0);
   - Cleanup COMPLETO no unmount (nesta ordem):
       gsap.ticker.remove(update);
       lenis.off('scroll', ScrollTrigger.update);
       lenis.destroy();
   - Expõe via Context (useLenis) a instância + um estado isScrolling
     (do callback de scroll do Lenis), como faz o site auditado.
3. Registre plugins uma única vez em lib/gsap.ts:
   gsap.registerPlugin(ScrollTrigger, useGSAP); exporte gsap.
4. Crie o hook "useHasEntered(ref, { threshold })": IntersectionObserver
   once que retorna hasEntered (boolean). É o ÚNICO gatilho de reveal
   do site inteiro: cada seção o usa para ligar sua classe '--entered'.
5. Crie um util "splitWords(texto)" (e "splitLinesAndWords") que quebra
   strings em arrays de palavras no RENDER (server-friendly): o markup
   já chega ao browser com os spans de reveal prontos, sem medição.
6. Hook usePrefersReducedMotion(); componentes degradam quando true.
7. Tokens no globals.css (easings do site auditado):
   :root {
     --ease-reveal: cubic-bezier(0.77, 0, 0.175, 1);
     --ease-ui: cubic-bezier(0.645, 0.045, 0.355, 1);
     --ease-soft: cubic-bezier(0.32, 0.72, 0, 1);
     --dur-reveal: 1s;
   }
Entregue os arquivos completos e o layout.tsx atualizado.

Passo 2 — O sistema nervoso tipográfico: reveal de palavras por seção (Motion 3)

Nenhum texto da Beetogreen aparece de uma vez: cada palavra sobe de dentro de uma fenda invisível, uma fração de segundo depois da anterior, num movimento firme e desacelerado. E quando um conteúdo sai de cena (troca de step, de slide), as palavras sobem e desaparecem pela borda de cima, abrindo espaço para as novas subirem por baixo. O site inteiro respira nesse compasso — quase todos os motions seguintes consomem este.

A arquitetura importa tanto quanto o efeito: o gatilho é uma classe na seção (nunca por palavra), e os três estados — futuro embaixo, visível no lugar, passado em cima — são o que permite trocas de conteúdo em fluxo contínuo nos Motions 8, 10 e 13.

txt
Crie o sistema de reveal de palavras em Next.js:

1. Util puro "splitWords(text): string[]" e componente
   "RevealWords({ text, as, delay = 0, step = 0.04 })" que renderiza:
   <Tag aria-label={text}>
     {words.map((w, i) =>
       <span class="w" aria-hidden="true" key={i}>
         <span class="w-inner"
           style={{ '--d': `${delay + i * step}s` }}>{w}</span>
       </span>)}
   </Tag>
   (split no render = SSR perfeito, zero medição, zero flash)
2. CSS:
   .w { display: inline-block; overflow: hidden;
        vertical-align: bottom; margin-right: .1em;
        padding-bottom: .12em; }   /* respiro p/ descendentes (g, p) */
   .w-inner { display: inline-block; transform: translateY(110%);
     transition: transform var(--dur-reveal) var(--ease-reveal);
     transition-delay: var(--d, 0s); }
   [data-entered="true"] .w-inner,
   .--active .w-inner { transform: translateY(0); }
   .--past .w-inner { transform: translateY(-110%); }
   (o estado "passado" sai por cima: é o que permite trocas de
   conteúdo em fluxo contínuo nos Motions 8, 10 e 13)
3. Gatilho por seção: cada section usa useHasEntered(ref,
   { threshold: 0.2 }) e escreve data-entered no próprio nó. Um
   único IO por seção, nunca por palavra.
4. Variante "AnimatedParagraph": mesmo padrão com step menor (0.01)
   para parágrafos longos.
5. prefers-reduced-motion: .w-inner sem transition (estado final).

Passo 3 — Vitórias rápidas em CSS puro: underline que viaja e marquee com máscara

Os links de texto vêm já sublinhados. No hover, o sublinhado se apaga escorrendo para a direita; ao sair, volta entrando pela esquerda. A linha nunca cresce e encolhe no mesmo lugar — ela viaja. É a mesma troca de transform-origin do Studio Modular, invertida: lá a linha se desenhava no hover; aqui ela se apaga. Detalhe de personalidade.

txt
Crie utilitários "u-underline-out" e estados de botão em Next.js:

1. Underline que se apaga:
   .u-underline-out { position: relative; display: inline-block;
     overflow: hidden; vertical-align: top; }
   .u-underline-out::after { content: ''; position: absolute;
     bottom: 0; left: 0; width: 100%; height: 1px;
     background: currentColor; transform: scaleX(1);
     transform-origin: left center;
     transition: transform .7s var(--ease-reveal); }
   .u-underline-out:hover::after { transform: scaleX(0);
     transform-origin: right center; }
   (repouso: linha cheia; hover: apaga saindo pela direita;
   unhover: redesenha entrando pela esquerda)
2. Botão com loading:
   .button[data-loading="true"] .button__label { opacity: 0; }
   .button__spinner { position: absolute; inset: 0; margin: auto;
     width: 1.2em; aspect-ratio: 1; border-radius: 50%;
     border: 2px solid currentColor;
     border-right-color: transparent;
     animation: spin 0.8s linear infinite; opacity: 0; }
   .button[data-loading="true"] .button__spinner { opacity: 1; }
   @keyframes spin { to { transform: rotate(1turn) } }
   aria-busy="true" junto com data-loading.
3. Badge opcional no botão (contadores/etiquetas) como span
   posicionado, sem animação própria.
4. Hovers só em @media (hover: hover); focus-visible com ring.

Motion 5 — Marquee parametrizado com máscara de fade

Uma esteira de logotipos desliza continuamente — com dois refinamentos que a separam do marquee comum: as bordas se desmancham em fade (os logos nascem e morrem suavemente nas laterais, via mask-image), e cada instância escolhe velocidade e sentido próprios por CSS vars. Server Component, zero JS.

txt
Crie um componente "FadeMarquee" em Next.js (Server Component):

1. Props: duration ('30s'), direction ('normal' | 'reverse'),
   gap ('6rem'), itemHeight ('3.2rem'), tint ('none' | 'dark' |
   'light'), fadeEdges (true), children (itens).
2. Estrutura: div.marquee (overflow hidden, width 100%,
   style com as CSS vars) > ul.marquee__list (inline-flex, gap,
   white-space nowrap, will-change transform, class "mq") com os
   itens renderizados DUAS vezes (aria-hidden na segunda cópia).
3. CSS:
   .mq { animation: marquee var(--marquee-duration) linear infinite;
         animation-direction: var(--marquee-direction); }
   @keyframes marquee { from { transform: translate(0) }
     to { transform: translate(-50%) } }
   .marquee--fade { mask-image: linear-gradient(90deg,
     transparent 0, #000 8%, #000 92%, transparent);
     -webkit-mask-image: /* idem */; }
   .marquee--tint-dark .logo { filter: brightness(0); }
   .marquee--tint-light .logo { filter: brightness(0) invert(1); }
4. A matemática do -50% exige as duas cópias idênticas; gap incluído
   dentro da cópia (padding-right no último item ou gap no flex com
   cópia começando alinhada).
5. Pausa opcional no hover; prefers-reduced-motion: animation none
   (faixa estática com overflow-x auto para ainda ser explorável).

Passo 4 — A moldura de entrada: preloader com Lottie e a pincelada de rota

Motion 1 — Preloader com logo animado + cortina

A tela abre num painel de cor sólida onde o logotipo se desenha sozinho (Lottie). Antes mesmo de a animação terminar, o painel sobe como cortina de palco, revelando o site pronto por trás. O detalhe de engenharia: o tempo da cortina é calculado da duração do próprio Lottie — nada é chute. E o scroll é liberado no meio da cortina, não no fim.

txt
Crie um componente client "Preloader" em Next.js:

1. Painel fixed inset-0 z-[60] com background sólido (token) e o
   <DotLottieReact> do logo centralizado (autoplay, sem loop),
   inicialmente opacity 0.
2. Fluxo (useGSAP + useLenis):
   - onMounted: lenis.stop();
   - quando o player estiver pronto: gsap.to(lottieWrap,
     { opacity: 1, duration: 0.1, ease: 'power2.out' });
     const dur = player.getDuration();      // segundos
     agende a cortina em dur * WIPE_RATIO (const, ex. 0.6);
     fallback: se a duração não vier, dispare imediatamente.
   - Cortina: tl = gsap.timeline({ delay });
     tl.to(panel, { clipPath: 'inset(0 0 100% 0)', duration: 1.2,
       ease: 'expo.inOut' });
     tl.call(() => { lenis.start(); onPreloaderDone(); }, [], 0.5);
     // libera scroll e reveals NO MEIO da cortina, não no fim
     tl.call(() => removePanel());
3. onPreloaderDone() é o sinal que o hero (Motion 3) espera para
   ligar '--entered' na primeira dobra.
4. Rode uma vez por sessão (sessionStorage); demais visitas: sem
   painel, sinal imediato.
5. prefers-reduced-motion: painel com fade rápido, sem Lottie.

Motion 2 — Transição de rota "pincelada"

A assinatura do site. Ao clicar num link, uma pincelada atravessa a tela: um traço orgânico começa fino, corre pelo caminho de um gesto desenhado e vai engordando até virar uma mancha que cobre tudo. A página troca por trás da tinta; o traço então se desfaz no sentido inverso. A cor da tinta pode mudar conforme o destino — uma transição com caligrafia, não um fade genérico.

A mecânica é um só <path> SVG: stroke-dashoffset desenha o gesto enquanto stroke-width engorda de 450 até 800, cobrindo a tela. E as regras de robustez valem ouro: Promises que resolvem uma única vez, timeline anterior sempre morta antes da nova.

txt
Crie o sistema de transição de rota em Next.js:

1. Componente "TransitionOverlay" (client, no layout raiz):
   div fixed inset-0 z-50 pointer-events-none visibility-hidden >
   svg (viewBox cobrindo a tela, scale 1.1, preserveAspectRatio
   "xMidYMid slice") > path do gesto (fill none, stroke currentColor,
   stroke-linecap round). Desenhe um path em S/diagonal que atravesse
   o viewBox; exponha setStrokeColor(cor).
2. API imperativa (Context): prepare(), playOut(cor), playIn():
   - prepare(): const len = path.getTotalLength();
     gsap.set(path, { strokeDasharray: len, strokeDashoffset: len,
       opacity: 0 }); gsap.set(svg, { scale: 1.1 });
   - playOut(cor): Promise que:
     seta a cor; gsap.set(wrapper, { pointerEvents: 'auto',
       visibility: 'visible', opacity: 1 });
     tl = gsap.timeline({ defaults: { duration: .7,
       ease: 'power1.inOut' } });
     tl.to(path, { opacity: 1, duration: .05 });
     tl.to(path, { strokeDashoffset: 0,
       attr: { 'stroke-width': 450 } }, '<');
     tl.call(() => { gsap.set(path, { strokeDashoffset: 0,
       attr: { 'stroke-width': 800 } }); resolve(); }, [], '>-0.2');
     // o salto final para 800 garante cobertura total sem custo
   - playIn(): Promise inversa (defaults .9s):
     ao completar, gsap.set(wrapper, { pointerEvents: 'none',
       visibility: 'hidden', opacity: 0 }) e reset do path
     (dashoffset = len, stroke-width 2, opacity 0).
   - SEMPRE mate a timeline anterior (tl?.kill()) antes de criar
     outra, e resolva a Promise uma única vez (flag).
3. "TransitionLink": intercepta o clique → await playOut(corDoDestino)
   → router.push → no template.tsx da nova rota, useEffect chama
   playIn() (e lenis.scrollTo(0, { immediate: true }) antes).
4. popstate: pule playOut; rode só playIn curto.
5. prefers-reduced-motion: substitua por fade de 0.3s no wrapper.

Passo 5 — A casca de navegação: header camaleão e nav mobile em cascata

Motion 6 — Header adaptativo por tema + véu de dropdown

O cabeçalho é uma cápsula fixa que muda de pele conforme a seção: branco sobre fundos claros, verde-escuro sobre seções escuras, sem quebra. O truque de arquitetura: o header consome apenas tokens (--nav-bg, --nav-text) e as variantes só trocam os tokens — a mudança de pele é uma classe. E ao abrir um submenu, um véu escurece o resto da página, focando a atenção.

txt
Crie um componente client "AdaptiveHeader" em Next.js:

1. header fixed top-0 z-40 com container-cápsula lendo tokens:
   .nav { --nav-bg: #fff; --nav-text: var(--tk-ink); }
   .nav--dark { --nav-bg: var(--tk-ink); --nav-text: #fafafa; }
   .nav--accent { --nav-bg: var(--tk-accent); --nav-text: #fafafa; }
   .nav__container { background: var(--nav-bg); color: var(--nav-text);
     transition: background .35s var(--ease-soft),
                 color .35s var(--ease-soft); border-radius: 999px; }
2. Tema por seção: seções marcam data-nav-theme="dark|light|accent";
   um IO com rootMargin "-50% 0px -50% 0px" (linha do meio) observa
   e seta a variante do header conforme a seção sob o topo.
3. Dropdown desktop: ao abrir (hover/click com aria-expanded),
   renderize o painel dentro da cápsula e ligue o véu:
   .nav__overlay { position: fixed; inset: 0;
     background: rgb(0 0 0 / .3); opacity: 0; pointer-events: none;
     transition: opacity .35s var(--ease-soft); z-index: 0; }
   .nav__overlay--visible { opacity: 1; }
   Clique no véu fecha o dropdown; Escape idem.
4. O container do header fica acima do véu (z-index 1) e o conteúdo
   da página abaixo: o véu foca sem bloquear o próprio menu.
5. Lang switcher como dropdown compacto com a mesma mecânica.

Motion 7 — Nav mobile com cascata de tags

O menu mobile abre e os itens não chegam juntos: cada link sobe um degrau com fade, um após o outro, como etiquetas sendo penduradas em sequência. E um detalhe de implementação que ensina o padrão central do site: keyframes, não transitions — porque keyframes re-executam quando o overlay remonta, dando replay limpo a cada abertura.

txt
Crie um componente client "MobileNav" em Next.js:

1. Botão hambúrguer (aria-expanded, aria-controls) + overlay
   fullscreen (montado condicionalmente para o replay dos keyframes).
2. CSS:
   @keyframes tag-reveal { from { opacity: 0;
     transform: translateY(50%); } to { opacity: 1;
     transform: translateY(0); } }
   .mnav__item { opacity: 0;
     animation: tag-reveal .45s var(--ease-ui) forwards;
     animation-delay: calc(.15s + var(--i) * .06s); }
   .mnav__social { /* mesmo keyframe, delays após os itens */ }
   Passe --i por style inline no map dos itens.
3. Fundo do overlay com fade/slide próprio (.35s) antes da cascata.
4. Acessibilidade: inert no overlay fechado, Escape fecha, foco
   gerenciado (primeiro item ao abrir, botão ao fechar),
   lenis.stop()/start().
5. prefers-reduced-motion: sem keyframes (itens visíveis direto).

Passo 6 — O padrão em profundidade: acordeão com palavras em cascata (Motion 13)

O FAQ parece simples, mas é onde o padrão "estado da seção dirige palavras" se prova em profundidade. O ícone "+" vira "−" recolhendo a barra vertical; a resposta desdobra em altura via grid-template-rows: 0fr → 1fr (sem medir um pixel); e o refinamento: só depois de aberta, as palavras da resposta sobem em cascata — o Motion 3 disparado pela classe --open do item, com um delay de cortesia de 0.3s.

txt
Crie um componente client "CascadeAccordion" em Next.js:

1. Itens com <button aria-expanded aria-controls> (pergunta em
   RevealWords) + ícone:
   .icon { position: relative; width: 2rem; height: 2rem; }
   .icon-line { position: absolute; top: 50%; left: 50%;
     background: currentColor;
     transition: transform .4s var(--ease-ui); }
   .icon-line--h { width: 100%; height: 1.5px;
     transform: translate(-50%, -50%); }
   .icon-line--v { height: 100%; width: 1.5px;
     transform: translate(-50%, -50%); }
   .item--open .icon-line--v { transform: translate(-50%, -50%)
     scaleY(0); }
2. Resposta:
   .answer-wrap { display: grid; grid-template-rows: 0fr;
     transition: grid-template-rows .5s var(--ease-ui); }
   .item--open .answer-wrap { grid-template-rows: 1fr; }
   .answer-inner { min-height: 0; overflow: hidden; }
3. Cascata pós-abertura: a resposta usa os spans do Motion 3 com:
   .item--open .answer .w-inner { transform: translateY(0);
     transition-delay: calc(0.3s + var(--d, 0s)); }
   (fechado, os inners voltam a translateY(110%) sem delay)
4. Um item aberto por vez (ou multi, por prop); Enter/Espaço nativos
   do button; divisórias de 1px entre itens.
5. prefers-reduced-motion: altura sem transition e palavras diretas.

Passo 7 — O par de autoplay: depoimentos com anel e citação que se reescreve

Motion 10 — Depoimentos com anel de contagem regressiva

Uma citação por vez, em letras grandes. A seta "próximo" carrega um segredo: um anel se desenha ao redor dela, completando a volta no tempo exato do autoplay — quando o círculo fecha, o slide troca. Pousar o mouse sobre a seta congela o anel (e o autoplay); sair retoma do zero.

O replay do anel é o padrão da casa: o SVG tem key={cycleCount} — cada troca incrementa a chave, o nó remonta, o keyframe recomeça. Simples e à prova de estados fantasma.

txt
Crie um componente client "RingTestimonials" em Next.js:

1. Dados: items[] { quote, author, role, avatar? } + cores por props.
   Estados: index, cycleKey (número), paused; INTERVAL = 6000.
2. Autoplay: schedule() = clearTimeout + cycleKey++ +
   setTimeout(next, INTERVAL); chame schedule() quando a seção entra
   (useHasEntered threshold 0.3) e após cada next()/prev() se
   !paused; clique nas setas navega e re-agenda.
3. Anel na seta next:
   <button class="arrow arrow--next" onMouseEnter={pause}
     onMouseLeave={resume} onFocus={pause} onBlur={resume}>
     <svg key={cycleKey} class="ring"
       style={{ '--progress-duration': `${INTERVAL}ms` }}
       viewBox="0 0 50 50"><circle cx="25" cy="25" r="24"/></svg>
     <ArrowIcon/>
   </button>
   CSS: .ring circle { fill: none; stroke: currentColor;
     stroke-width: 1; stroke-dasharray: 151;    /* 2πr */
     stroke-dashoffset: 151;
     animation: ring-fill var(--progress-duration) linear forwards; }
   @keyframes ring-fill { to { stroke-dashoffset: 0 } }
   .arrow--paused .ring circle { animation-play-state: paused; }
   pause(): paused=true + clearTimeout; resume(): paused=false +
   schedule() (anel recomeça do zero via nova key).
4. Contador "01 / 05" (padStart) com aria-live="polite"; citação e
   autor trocam via Motion 11.
5. prefers-reduced-motion: sem autoplay nem anel (navegação manual).

Motion 11 — Citação que se reescreve palavra a palavra

Quando o depoimento troca, a citação nova não desliza de lado: ela se reescreve no mesmo lugar, cada palavra subindo de sua fenda em cascata rápida — incluindo as aspas, que participam como "palavras". É o replay por chave no seu uso mais puro: key={index}, o React remonta, os keyframes rodam do zero. Sem timeline, sem estado de animação. A chave é o play.

txt
Crie um componente "RewriteText" em Next.js:

1. Props: text, replayKey (a chave que força a remontagem), step
   (delay entre palavras, default 0.03), baseDelay (0.1).
2. Render:
   <p key={replayKey} aria-label={text} class="rewrite">
     {splitWords(`“${text}”`).map((w, i) =>
       <span class="w" aria-hidden="true">
         <span class="w-inner"
           style={{ animationDelay:
             `${baseDelay + i * step}s` }}>{w}</span></span>)}
   </p>
   (as aspas tipográficas entram como "palavras" e participam da
   cascata, como no site auditado)
3. CSS (keyframes, não transition: precisa rodar a cada remontagem):
   .rewrite .w { display: inline-block; overflow: hidden;
     padding-bottom: .12em; }
   .rewrite .w-inner { display: inline-block;
     transform: translateY(100%);
     animation: word-in .8s var(--ease-reveal) forwards; }
   @keyframes word-in { to { transform: translateY(0) } }
4. Use para citação, autor e contador do Motion 10 (chaves iguais,
   baseDelay maior nos secundários).
5. prefers-reduced-motion: animation none, transform none.

Passo 8 — A seção mais viva: processo autoplay com cortinas e cor viva (Motion 8)

A seção "como funciona" se apresenta sozinha: a cada 4 segundos avança um passo. Na troca, quatro coisas acontecem em harmonia: a imagem nova sobe cobrindo a anterior como cortina (clip-path de baixo para cima com zoom assentando), os textos velhos saem palavra a palavra por cima enquanto os novos entram por baixo (Motion 3), a linha de progresso preenche no ritmo exato dos 4s, e — o toque de mestre — a cor de fundo e de texto da seção inteira mudam para a paleta daquele passo, vinda do CMS. Clicar num passo assume o controle: o autoplay para de vez e a linha passa a preencher rápido.

Este motion é a tese do site provada: cores no style, transições no CSS, estados dirigindo tudo — e a linha de progresso trocando de transition-duration (4s linear no autoplay, 0.8s com easing no clique) só pela presença da classe --auto.

txt
Crie um componente client "AutoProcess" em Next.js:

1. Dados: steps[] { label, smallLabel, title, description, imageUrl,
   bgColor, textColor } (CMS). Estados: active (índice),
   entered (useHasEntered threshold 0.2), manual (boolean).
2. Autoplay: ao entered, se !manual: timeout de 4000ms avança active
   (para no último); clique num passo seta manual=true, cancela o
   timeout e vai direto ao índice. Cleanup do timeout no unmount.
3. Section com style { backgroundColor: stepAtivo.bgColor,
   color: stepAtivo.textColor } e
   transition: background-color .8s var(--ease-ui), color .8s.
   Classes: --entered e --auto (entered && !manual).
4. Lista de passos: cada item com botão (aria-current="step" no
   ativo), rótulo em RevealWords e a linha:
   .step-line { height: 1px; background:
     color-mix(in srgb, currentColor 20%, transparent);
     overflow: hidden; }
   .step-line-fill { height: 100%; background: currentColor;
     transform: scaleX(0); transform-origin: left; }
   .step--active .step-line-fill { transform: scaleX(1);
     transition: transform .8s var(--ease-reveal); }
   .--auto .step--active .step-line-fill {
     transition: transform 4s linear; }   /* sincronizada ao autoplay */
5. Visual: camadas absolutas empilhadas na ordem dos steps:
   .layer { clip-path: inset(100% 0 0 0);
     transition: clip-path 1.2s var(--ease-reveal); }
   .layer--active, .layer--past { clip-path: inset(0 0 0 0); }
   .layer img { transform: scale(1.08); transition: transform 1.2s
     var(--ease-reveal); }
   .layer--active img { transform: scale(1); }
6. Conteúdo textual por step com estados --active/--past/--future
   alimentando o Motion 3 (palavras saem por cima, entram por baixo).
7. Formas decorativas (Motion 15) por step: camada com fade .6s e
   replay ao ativar.
8. prefers-reduced-motion: sem autoplay (navegação só por clique) e
   trocas por fade simples.

Passo 9 — O único pin do site: cards com tombamento 3D (Motion 9)

Um fundo cola na tela e os cards de estatísticas chegam um por cima do outro; enquanto isso, o card que está sendo coberto tomba para trás em perspectiva — inclina, gira um fio e encolhe, como uma carta sendo deitada na mesa pela próxima. Rolar para trás re-ergue as cartas na ordem inversa.

O segredo mecânico: pin: true, pinSpacing: false em todos os cards — todos pinam no mesmo ponto e se acumulam por z-index natural, sem espaçadores. E perspective no pai é obrigatório, ou o rotateX simplesmente não existe visualmente.

txt
Crie um componente client "TiltStack" em Next.js:

1. Estrutura: section.stats > div.stats__bg (100vh, o palco que
   pina) + N section.stats__card (cada um 100vh, conteúdo central
   com número/frase gigante). CSS: .stats { perspective: 1200px; }
   nos wrappers dos cards (necessário para o rotateX aparecer).
2. useGSAP (guarde os triggers num array para cleanup):
   const cards = gsap.utils.toArray('.stats__card');
   const last = cards[cards.length - 1];
   triggers.push(ScrollTrigger.create({ trigger: bgRef.current,
     start: 'top top', endTrigger: last, end: 'top top',
     pin: true, pinSpacing: false }));
   cards.forEach((card, i) => {
     triggers.push(ScrollTrigger.create({ trigger: card,
       start: 'top top', endTrigger: last, end: 'top top',
       pin: true, pinSpacing: false }));
     if (i < cards.length - 1) {
       const next = cards[i + 1];
       triggers.push(ScrollTrigger.create({ trigger: next,
         start: 'top bottom', end: 'top top', scrub: 0.5,
         onUpdate: (self) => {
           const p = self.progress;
           gsap.set(card, { rotate: 4 * p, rotateX: 36 * p,
             scale: 1 - 0.2 * p });
         } }));
     }
   });
   Cleanup: triggers.forEach(t => t.kill()).
   Nota: pinSpacing: false é o que faz os cards se ACUMULAREM (sem
   espaçadores); a altura total da seção = N viewports naturais.
3. Números dos cards podem usar RevealWords (Motion 3) ao entrar.
4. Mobile: desative (gsap.matchMedia >= 1024px) e caia para uma
   lista vertical simples; reduced-motion idem.
5. ScrollTrigger.refresh() após imagens/fonts (pin depende do
   layout final).

Passo 10 — Interações de conteúdo: abas com saída limpa e a calculadora viva

Motion 12 — Cards em abas com transição de saída/entrada

Ao trocar de aba, os cards atuais se despedem juntos (fade descendo) e o novo conjunto assume, com os textos se reescrevendo. A regra editorial: nunca dois conjuntos visíveis brigando — AnimatePresence mode="wait" garante que sai tudo, depois entra tudo. E reserve a altura do maior conjunto, ou a troca bombeia o layout.

txt
Crie um componente client "TabbedCards" em Next.js:

1. Tabs acessíveis (role=tablist/tab/tabpanel, aria-selected,
   setas de teclado). Estado: activeTab.
2. Com Framer Motion (recomendado):
   <AnimatePresence mode="wait">
     <motion.div key={activeTab} class="cards-grid"
       initial={{ opacity: 0, y: 10 }}
       animate={{ opacity: 1, y: 0 }}
       exit={{ opacity: 0, y: 10 }}
       transition={{ duration: 0.4,
         ease: [0.645, 0.045, 0.355, 1] }}>
       {cards.map(…)}
     </motion.div>
   </AnimatePresence>
   (mode="wait" garante: sai tudo, depois entra tudo)
3. Sem Framer: key={activeTab} + keyframe de entrada
   (opacity 0/y 10 → 1/0, .4s var(--ease-ui)); a saída vira corte
   seco: aceitável, mas registre o downgrade.
4. Títulos dos cards com RevealWords (delay em cascata pelos cards:
   --entry-delay = i * .08s).
5. Altura: reserve min-height do maior conjunto (medida no mount)
   para a troca não bombear o layout.

Motion 14 — Calculadora interativa com resultados vivos

Três sliders, e nenhum botão "calcular": arrastar qualquer um recalcula tudo ao vivo — o número-resultado gigante muda na hora, formatado no padrão local, e cartões de equivalência traduzem o número em comparações palpáveis. O upgrade de motion: amortecer o número com um contador GSAP curto (~0.4s), para o valor "correr" até o novo resultado em vez de trocar seco.

txt
Crie um componente client "LiveCalculator" em Next.js:

1. Props: fórmulas e fatores parametrizáveis (nada hard-coded de
   domínio); 3 sliders (input range estilizado com track/thumb
   custom) com rótulo e valor ao lado.
2. Cálculo: useMemo derivando resultado e N equivalências dos três
   valores; formatação com Number.prototype.toLocaleString(locale).
3. Número vivo: mantenha um ref { v: atual } e, a cada mudança do
   resultado, gsap.to(ref, { v: novo, duration: 0.4,
   ease: 'power2.out', onUpdate: () => el.textContent =
   format(ref.v) }); (contagem curta, não "slot machine")
4. Sliders acessíveis: label + aria-valuetext com unidade; passo e
   limites por props; atualização em onInput (não onChange) para o
   vivo de verdade.
5. Cartões de equivalência com micro-transition de opacity ao mudar
   (classe piscada) e RevealWords na entrada da seção.
6. prefers-reduced-motion: número troca direto, sem contagem.

Passo 11 — O fechamento: formas de marca e rodapé parallax

Motion 15 — Formas decorativas de marca por seção

As formas orgânicas da identidade — as curvas gigantes que você vê recortando o hero da Beetogreen — vivem atrás do conteúdo, cada uma com posição, tamanho e rotação próprios. Elas não são estáticas: trocam com fade quando o estado da seção muda (cada passo do processo tem a sua) e reexecutam uma entrada sutil ao voltar à cena — de novo o replay por chave.

txt
Crie um componente "BrandShape" em Next.js:

1. Props: shape (chave num registry de SVGs da marca), position
   ({ top, left }), size ('60rem'), rotation (graus), color,
   centered (boolean), replayKey (opcional).
2. Render: div.shape (position absolute, pointer-events none,
   z-index 0, aria-hidden) com style { top, left, width: size,
   transform: `${centered ? 'translate(-50%,-50%)' : ''}
   rotate(${rotation}deg)` } > SVG (fill/stroke currentColor,
   color da prop).
3. Camadas por estado (uso no Motion 8): uma div.shape-layer por
   estado, absolute inset-0, opacity 0, transition opacity .6s
   var(--ease-reveal); a ativa ganha --active { opacity: 1 } e a
   forma interna com key={replayKey} remonta para reexecutar um
   keyframe de entrada leve (scale .96 → 1 + fade .6s).
4. Mobile: fator de escala (~0.6) aplicado ao size e recentragem
   (top/left 50% + centered), via prop ou matchMedia no pai.
5. As formas são sempre decorativas: nunca recebem foco nem eventos.

Motion 16 — Rodapé parallax + voltar ao topo suave

O rodapé chega em parallax de revelação: começa 25% "atrasado" para cima e pousa na posição ao fim da rolagem. O detalhe que evita bugs em produção: clamp() nos dois pontos do trigger — sem ele, páginas mais curtas que o percurso fazem o trigger nascer "já começado" e o parallax pula. E o botão "voltar ao topo" leva a página num voo suave de 1,5s via Lenis, não num teletransporte.

txt
Crie um componente client "ParallaxFooter" em Next.js:

1. Estrutura: div.footer-wrap (ref externa) > footer.footer
   (ref interna) com colunas, sociais e créditos.
2. useGSAP (gate: matchMedia sem phone, reduced motion false):
   tween = gsap.from(innerRef.current, {
     yPercent: -25, ease: 'none',
     scrollTrigger: { trigger: wrapRef.current,
       start: 'clamp(top bottom)', end: 'clamp(bottom bottom)',
       scrub: true } });
   Cleanup: tween.scrollTrigger?.kill(); tween.kill();
   (clamp() nos dois pontos: essencial para rodapés, evita o trigger
   estourar em páginas mais curtas que o percurso)
3. Sociais com keyframe de cascata (mesmo padrão do Motion 7,
   social-reveal: opacity 0 + translateY(50%) → visível, delays
   por índice) disparado por useHasEntered no rodapé.
4. Botão "voltar ao topo":
   const toTop = () => lenis
     ? lenis.scrollTo(0, { duration: 1.5 })
     : window.scrollTo({ top: 0, behavior: 'smooth' });
5. Links com o underline do Motion 4.

As 15 armadilhas que vão te morder (leia antes de codar)

  • "use client" obrigatório em todo componente que toca GSAP, Lenis ou observers.
  • Sempre useGSAP({ scope }) em vez de useEffect cru: o context.revert() automático mata tweens e triggers órfãos ao navegar entre rotas.
  • Cleanup do Lenis é em 3 passos: gsap.ticker.remove(update) + lenis.off('scroll', ...) + lenis.destroy().
  • Split de palavras no render, não no browser: quebrar o texto em spans durante o render (util puro) elimina medição de DOM, flash e mismatch de hidratação; reserve SplitText para casos que exijam quebra por LINHA real.
  • Replay por chave é um padrão, use-o com intenção: keyframes CSS re-executam quando o nó remonta (key={n}); é como o site auditado reinicia citações, anéis e formas. Transitions NÃO re-executam assim — escolha keyframe vs transition pelo ciclo de vida.
  • Timeouts de autoplay têm dono único: schedule() sempre começa com clearTimeout; pausar cancela; retomar re-agenda do zero; unmount limpa. Dois timers vivos = slides pulando.
  • Promises de transição resolvem UMA vez: guarde a flag, mate a timeline anterior (tl?.kill()) antes de criar outra, e resolva também no caminho de erro — a navegação nunca pode ficar presa atrás do overlay.
  • pinSpacing: false empilha, pin sozinho não: o acúmulo de cards do Motion 9 depende de pinar TODOS no mesmo ponto sem espaçadores; e perspective no pai é obrigatório para o rotateX existir visualmente.
  • clamp() em triggers de rodapé: sem ele, páginas curtas fazem o trigger nascer "já começado" e o parallax pula.
  • Cores dinâmicas do CMS via style inline + transition no CSS: a seção que muda de cor por estado anima porque a transition mora no CSS e o valor no style — nunca anime cor via JS por frame.
  • grid-template-rows: 0fr → 1fr para altura animada (acordeão): filho com min-height: 0; overflow: hidden; nunca meça pixels.
  • Anel SVG: stroke-dasharray = circunferência real (2πr do viewBox, não do tamanho renderizado); errar esse número quebra o "fecho" do anel com o fim do autoplay.
  • Máscara de marquee custa GPU: mask-image em faixas largas é razoável, mas evite empilhar com backdrop-filter no mesmo eixo de scroll.
  • ScrollTrigger.refresh() após load de imagens e fontes; pins e scrubs posicionais dependem do layout final.
  • prefers-reduced-motion em tudo: sem autoplay, sem anel, sem parallax, sem tombamento; conteúdo direto no estado final — o site auditado desliga na raiz, não "suaviza".

Por onde começar hoje

O trio de entrada: Passo 1 (a infra — useHasEntered e splitWords são meia hora que rendem o site inteiro), Passo 2 (o reveal de palavras — é o sistema nervoso; com ele pronto, cada seção nova já nasce respirando) e Passo 3 (underline e marquee — CSS puro, resultado imediato).

E a série agora soma quatro filosofias: as variáveis contínuas da Plantica, as três camadas da ANAI, os módulos com gates do Studio Modular — e os estados por seção da Beetogreen, com o replay por chave como o truque mais React-idiomático de todos. Quatro sites, nenhuma biblioteca mágica em comum: o que se repete é a disciplina. Estados são a única API — o resto é o CSS fazendo o que sabe fazer.

Leia também