Pular para o conteúdo
Zumkai

Motions premium em Next.js: os 15 padrões do site do Studio Modular, passo a passo

A arquitetura componentizada do site do Studio Modular — módulos com gates, scroll nativo e CSS de ponta — em 15 padrões de motion com prompts prontos.

  • motion
  • gsap
Home do Studio Modular: tipografia gigante verde-escura sobre fundo creme, navegação em pílula de vidro e o selo “How we roll”
Sumário
  1. A filosofia: módulos que sabem quando não rodar
  2. A stack e o papel de cada peça
  3. O mapa dos 15 motions
  4. Passo 1 — A fundação: infraestrutura e os três gates
  5. Passo 2 — Vitórias rápidas em CSS puro: o botão e o selo de scroll
  6. Passo 3 — O cartão de visitas: wordmark bipartido que se monta e se desmonta
  7. Passo 4 — A joia técnica: nav pill com CSS Anchor Positioning (Motion 4)
  8. Passo 5 — O padrão de acessibilidade: menu que floresce em círculo (Motion 5)
  9. Passo 6 — O scrub mais sofisticado: texto "tinta sobre o rascunho" (Motion 7)
  10. Passo 7 — Scrubs declarativos em série: parallax e morph circular
  11. Passo 8 — Sticky com honestidade: cards empilhados com medição de encaixe (Motion 8)
  12. Passo 9 — O marquee que obedece à sua rolagem (Motion 6)
  13. Passo 10 — O conjunto do portfólio: perseguidor de cursor e grades responsivas
  14. Passo 11 — O fechamento: depoimentos em loop e o "tocando agora"
  15. As 15 armadilhas que vão te morder (leia antes de codar)
  16. Por onde começar hoje

Terceiro estudo de engenharia reversa da série — e a terceira filosofia de animação completamente diferente. No guia da Plantica, o JS quase não animava: escrevia variáveis e o CSS movia. No guia da ANAI, três camadas dividiam o trabalho entre vars, tweens e canvas. O site desta vez responde com a abordagem mais disciplinada das três — e provavelmente a mais transferível para o seu dia a dia com React.

O auditado é o Studio Modular (studiomodular.be), estúdio belga de design gráfico — tipografia gigante verde-escura sobre creme, nav numa pílula de vidro, o selo "How we roll" girando no hero e um convite direto: "Start jouw project". Por trás da personalidade, um tema 100% componentizado sobre CMS multi-página: GSAP, ScrollTrigger e SplitText carregados sob demanda, Swiper, e CSS de ponta fazendo trabalho que você juraria ser JavaScript — Anchor Positioning, :has(), clip-path: circle(). Os 15 padrões abaixo foram catalogados auditando esse site e traduzidos para o ecossistema Next.js; nomes, textos e imagens 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 na série, a ordem dos passos é a ordem de construção — do alicerce ao charme final.

A filosofia: módulos que sabem quando não rodar

A assinatura de engenharia do Studio Modular não é um efeito — é uma disciplina. Cada motion é um módulo isolado que segue quatro regras:

  • Inicializa por data-attribute no markup (data-parallax, data-marquee-trigger...): o efeito é declarado onde vive, não configurado num arquivo central.
  • Importa GSAP dinamicamente só quando precisa: página sem motion não paga o custo.
  • Recusa-se a inicializar quando prefers-reduced-motion: reduce está ativo ou o breakpoint não comporta o efeito — repare: não é "reduzir a animação", é nem montar a lógica. Custo zero para quem não vai ver.
  • Observa a si mesmo: ResizeObserver para se recalcular, IntersectionObserver para pausar trabalho fora do viewport.

E duas decisões estruturais que destoam dos guias anteriores: o scroll é 100% nativo (nenhuma biblioteca de smooth scroll — e tudo funciona perfeitamente), e boa parte da sofisticação é CSS moderno puro: o indicador deslizante do menu é Anchor Positioning sem uma linha de JS, o overlay floresce com clip-path: circle(), hovers compostos usam :has().

A stack e o papel de cada peça

BibliotecaPapel no projetoPor quê
GSAP 3.13+ e @gsap/reactTimelines de entrada do hero, scrubs (texto, wordmark, morph, parallax), ticker do marquee, quickTo do perseguidorGSAP é 100% gratuito, incluindo todos os plugins, desde a aquisição pela Webflow
ScrollTrigger (plugin GSAP)Todos os efeitos atrelados à rolagem (scrub)O site auditado usa scroll nativo + ScrollTrigger direto, sem biblioteca de smooth scroll
SplitText (plugin GSAP)Split em linhas para o reveal "tinta sobre o rascunho"Re-split automático em resize; gate de fontes obrigatório
Embla Carousel (ou Swiper)Slider de depoimentos em loopO site auditado usa Swiper (slidesPerView auto, loop); Embla é o equivalente idiomático em React
CSS modernoAnchor Positioning (nav pill), clip-path: circle() (menu), :has() (hovers compostos), backdrop-filterBoa parte dos motions deste catálogo é CSS puro com progressive enhancement

Sem Lenis neste catálogo: o site auditado roda em scroll nativo e todos os efeitos funcionam perfeitamente assim. Se o seu projeto pedir o feel "amanteigado", adicione Lenis pelo padrão dos dois guias anteriores (sincronização + cleanup em 3 passos); nenhum motion daqui conflita.

O mapa dos 15 motions

#MotionUso típicoGatilho
1Abertura do hero: wordmark bipartido + mídias que brotamHero da homeLoad
2Separação do wordmark no scrollHero da homeScroll
3Selo "scroll" com texto circular giratórioHeroLoop automático
4Nav pill deslizante (Anchor Positioning)Header desktopHover
5Menu overlay que floresce em círculoMenu mobile/globalClique
6Marquee sensível à direção do scrollFaixas de texto/logosLoop + Scroll
7Texto scrub "tinta sobre o rascunho"Manifesto/intro longaScroll
8Cards empilhados sticky com medição de encaixeLista de serviços/planosScroll
9Morph de mídia para círculoDivisor de seçõesScroll
10Parallax declarativo por data-attributesImagens editoriaisScroll
11Perseguidor de cursor com selo giratórioCards de projetoHover
12Botão com círculo que troca de ladoCTAs globaisHover
13Slider de depoimentos em loopProva socialArraste/Setas
14Grades responsivas: masonry alternada + tiles fundidosPortfólio e listas de linksResize
15Widget "tocando agora"Rodapé (toque pessoal)Loop/Hover

O site auditado é multi-página sem transições de rota; a coesão vem da consistência dos motions. Em Next.js, um cross-fade leve via template.tsx é um complemento opcional que não pertence ao catálogo.

Passo 1 — A fundação: infraestrutura e os três gates

Tudo depende deste passo — e ele é diferente dos guias anteriores: em vez de um provider de smooth scroll, o coração aqui são os hooks de gate que todos os módulos usam como guarda-chuvas. É a disciplina do site auditado codificada: reduced motion, breakpoint e fontes checados antes de qualquer lógica montar.

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

1. Instale: gsap @gsap/react embla-carousel-react
2. Registre os plugins uma única vez em lib/gsap.ts:
   gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP); exporte gsap.
   Importado apenas por componentes client.
3. Crie os hooks utilitários que TODOS os motions usarão como
   guarda-chuvas (é o padrão do site auditado):
   - usePrefersReducedMotion(): lê '(prefers-reduced-motion: reduce)';
     componentes de motion NÃO inicializam efeitos quando true (não é
     só "reduzir": muitos nem montam a lógica).
   - useMediaQuery(query): reativo a matchMedia, para gates de
     breakpoint por componente (ex.: parallax só em >= 980px,
     stacking só em >= 1440px).
   - useFontsReady(family: string): resolve quando
     document.fonts.load(`16px '${family}'`) retorna fontes; gate
     obrigatório antes de qualquer SplitText.
4. Tokens de design no globals.css (o site auditado deriva medidas de
   animação dos tokens):
   :root {
     --tk-radius: 1.25rem;          /* raio padrão de mídias/cards */
     --tk-btn-height: 3.5rem;
     --tk-header-height: 4.5rem;
     --tk-speed-ui: 0.25s;
     --tk-stacking-gap: 1.25rem;
   }
5. Padrão de componente de motion (documente em um README interno):
   "use client" + useGSAP({ scope }) + gates (reduced motion,
   breakpoint, fontes) + ResizeObserver para recálculo +
   IntersectionObserver para pausar trabalho fora do viewport.
   Nada de listeners globais fora de cleanup.
6. ScrollTrigger.refresh() centralizado: após load de imagens
   relevantes e mudanças de layout (o util pode expor
   refreshOnImagesLoaded(scope)).
Entregue os arquivos completos.

Passo 2 — Vitórias rápidas em CSS puro: o botão e o selo de scroll

Dois componentes sem uma linha de GSAP que estabelecem os tokens e já dão cara ao projeto.

Motion 12 — Botão com círculo que troca de lado

O CTA-pílula tem um círculo de cor com uma seta encostado numa ponta. No hover, uma troca coreografada: o círculo original encolhe até sumir enquanto um idêntico brota na ponta oposta, a seta gira ajustando a mira e o rótulo recolore. O olho lê: "o botão apontou para onde você vai". E o bônus de arquitetura: o padrão trigger-by-child — quando o botão vive num card, :has() no pai dispara o hover do botão ao pairar em qualquer área do card.

txt
Crie um componente "CircleSwapButton" em Next.js (CSS puro):

1. Markup: <a class="btn"> <span class="circle" aria-hidden>
   <i>→</i></span> <span class="label">Rótulo</span>
   <span class="circle" aria-hidden><i>→</i></span> </a>
2. CSS:
   .btn { position: relative; display: inline-flex; align-items:
     center; height: var(--tk-btn-height); border-radius: 999px;
     padding-inline: 0 var(--tk-btn-height);
     transition: padding-inline .25s; }
   .btn:active { scale: .98; }
   .circle { position: absolute; width: var(--tk-btn-height);
     aspect-ratio: 1; border-radius: 50%;
     background: var(--tk-btn-circle-bg);
     display: grid; place-items: center;
     transition: scale .2s ease-in; }
   .circle:first-child { left: 0; transform-origin: left center;
     scale: 1; } .circle:first-child i { rotate: -45deg; }
   .circle:last-child { right: 0; transform-origin: right center;
     scale: 0; } .circle:last-child i { rotate: 45deg; }
   .circle i { transition: rotate .2s ease-in; }
   .btn:is(:hover, :focus-visible) {
     padding-inline: var(--tk-btn-height) 0; }
   .btn:is(:hover, :focus-visible) .circle:first-child { scale: 0; }
   .btn:is(:hover, :focus-visible) .circle:last-child { scale: 1; }
   .btn:is(:hover, :focus-visible) .circle i { rotate: 0deg; }
   .btn:is(:hover, :focus-visible) .label {
     color: var(--tk-btn-color-hover);
     background: var(--tk-btn-bg-hover); }
   (o padding-inline trocando de lado abre espaço para o círculo da
   ponta oposta sem mudar a largura total)
3. Padrão trigger-by-child (hover do card aciona o botão):
   .trigger-parent:has(.trigger-child:is(:hover, :focus)) .btn
   { …mesmas regras do hover… }
4. Variantes de cor por CSS vars (--tk-btn-*) em modifiers.
5. focus-visible com ring próprio além do estado; touch mantém o
   estado de repouso.

Motion 3 — Selo "scroll" com texto circular giratório

Um pequeno selo redondo no canto do hero: uma frase curta escrita em círculo, girando continuamente como um carimbo em rotação lenta, com uma setinha que quica no centro. Discreto, hipnótico, e diz "role" sem dizer. SVG com textPath + keyframes — zero JS.

txt
Crie um componente "ScrollBadge" em Next.js (Server Component, só CSS):

1. SVG acessível (aria-hidden no decorativo + texto sr-only "Role para
   baixo"): <svg viewBox="0 0 100 100"> <defs> <path id="ring"
   d="M50,50 m-38,0 a38,38 0 1,1 76,0 a38,38 0 1,1 -76,0"/> </defs>
   <text><textPath href="#ring">FRASE CURTA • FRASE CURTA •
   </textPath></text> </svg> + ícone de seta central.
2. CSS:
   .badge-text svg { animation: badge-rotate 16s linear infinite; }
   @keyframes badge-rotate { to { transform: rotate(360deg) } }
   .badge-icon { animation: badge-bounce 1.6s ease-in-out infinite; }
   @keyframes badge-bounce { 0%, 100% { transform: translate(-10%) }
     50% { transform: translate(10%) } }
   (o bounce corre no eixo em que a seta aponta; para seta vertical,
   troque por translateY)
3. transform-origin: center no SVG; tamanho fluido com clamp().
4. O selo é um link para a primeira seção (scroll suave nativo via
   scroll-behavior ou scrollIntoView).
5. prefers-reduced-motion: animation: none nos dois.

Passo 3 — O cartão de visitas: wordmark bipartido que se monta e se desmonta

O hero valida timeline e scrub no mesmo componente — os dois modos do GSAP trabalhando em sequência.

Motion 1 — Abertura: o mecanismo se monta

A primeira cena se monta como um mecanismo. O nome da marca chega partido ao meio na horizontal: a metade de cima desliza vinda da esquerda e a de baixo vinda da direita, encaixando-se como duas placas de uma prensa. Um instante depois, as fotografias brotam do próprio centro — começam como um ponto e crescem até a moldura completa, mantendo os cantos arredondados durante o crescimento. Nada "aparece": tudo se constrói.

O detalhe técnico que separa o bom do impecável: manter o round r nos dois estados do clip-path. Omita o raio de um lado e o canto "pula" no primeiro frame.

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

1. Estrutura: section[data-hero] >
   - div.logo com DUAS fileiras de SVG: [data-logo-top] (metade
     superior das letras) e [data-logo-btm] (metade inferior); o
     wordmark é um SVG cortado ao meio na horizontal, cada metade com
     overflow-hidden no wrapper;
   - figure[data-hero-media-1] e figure[data-hero-media-2]
     (next/image fill, moldura com border-radius: var(--tk-radius));
   - p[data-hero-text].
2. Timeline em useGSAP (gate: usePrefersReducedMotion() === false):
   const r = getComputedStyle(document.documentElement)
     .getPropertyValue('--tk-radius').trim();
   const tl = gsap.timeline();
   tl.fromTo('[data-logo-top] svg', { xPercent: -100 },
     { xPercent: 0, duration: 1.5, ease: 'power4.out' }, 0)
     .fromTo('[data-logo-btm] svg', { xPercent: 100 },
     { xPercent: 0, duration: 1.5, ease: 'power4.out' }, 0)
     .fromTo('[data-hero-media-1]',
       { clipPath: `inset(50% 50% 50% 50% round ${r})` },
       { clipPath: `inset(0% 0% 0% 0% round ${r})`,
         duration: 1.2, ease: 'power4.out' }, 0.2)
     .fromTo('[data-hero-media-2]', { …idem… }, { …idem… }, 0.4)
     .fromTo('[data-hero-text]', { opacity: 0 },
       { opacity: 1, duration: 1.2, ease: 'power4.out' }, 0.3);
   (manter o "round r" nos DOIS estados do clip é o que preserva os
   cantos arredondados durante o crescimento)
3. Imagens com priority + placeholder blur; dispare a timeline após
   o decode() das mídias do hero para o clip nunca revelar imagem
   vazia.
4. prefers-reduced-motion: componente monta tudo no estado final,
   sem timeline (o site auditado nem inicializa o módulo).

Motion 2 — Separação: a prensa reabre no scroll

O encaixe da abertura se desfaz em câmera controlada: conforme você rola, a metade de cima do nome desliza para a direita e a de baixo para a esquerda — o caminho inverso da chegada — até saírem do quadro. A velocidade é a da sua rolagem: parou, congelou.

txt
Estenda o HeroIntro com a separação por scroll:

1. Ainda no useGSAP, após a timeline de entrada:
   const st = {
     trigger: heroRef.current,
     scrub: 1,
     invalidateOnRefresh: true,
     start: () => `top ${heroRef.current.getBoundingClientRect().top
       + window.scrollY - 10}px`,
     end: 'bottom top'
   };
   gsap.fromTo('[data-logo-top] svg', { xPercent: 0 },
     { scrollTrigger: st, xPercent: 100, ease: 'none' });
   gsap.fromTo('[data-logo-btm] svg', { xPercent: 0 },
     { scrollTrigger: st, xPercent: -100, ease: 'none' });
   (o mesmo OBJETO de configuração pode ser passado aos dois tweens:
   cada tween cria seu próprio ScrollTrigger a partir dele, como faz o
   site auditado; o que NÃO se compartilha é uma instância já criada.
   Alternativa mais limpa: uma timeline única com um trigger só)
2. A entrada (Motion 1) termina em xPercent: 0: o scrub parte desse
   mesmo valor; se o usuário rolar durante a entrada, o overwrite
   padrão do GSAP resolve (deixe overwrite: 'auto').
3. ease 'none' é obrigatório em scrub: a suavização vem do scrub: 1.
4. prefers-reduced-motion: sem o scrub (o wordmark rola normalmente
   com a página).

Passo 4 — A joia técnica: nav pill com CSS Anchor Positioning (Motion 4)

O menu do topo é uma cápsula de vidro fosco. Dentro dela, uma pastilha de cor marca a página ativa; ao passar o mouse sobre outro link, a pastilha desliza fisicamente pela cápsula até ele, como um cursor magnético, e volta ao ativo quando o mouse sai.

Todo mundo já implementou isso medindo offsets com JavaScript. O Studio Modular não: é CSS Anchor Positioning puro — o link ativo declara anchor-name, o link em hover declara outro, e a pastilha é um ::before cuja âncora troca via :has(), com transition de left. Zero JavaScript, e o deslize acompanha até o foco por teclado, de graça. Progressive enhancement de manual: embrulhado em @supports, com fallback digno.

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

1. Estrutura: nav central fixa > ul.menu (pílula: background
   rgba(255,255,255,.8), backdrop-filter: blur(7px), border-radius
   pill, overflow hidden, height var(--tk-btn-height)) > links com
   padding-inline generoso.
2. CSS (progressive enhancement com @supports):
   @supports (anchor-name: --a) {
     .menu { --pad: 1.5rem; --off: .625rem; }
     .menu a[aria-current="page"] { anchor-name: --nav-active; }
     .menu a:is(:hover, :focus-visible) { anchor-name: --nav-hover; }
     .menu::before {
       content: ''; position: absolute; z-index: -1;
       position-anchor: --nav-active;
       left: calc(anchor(start) + var(--off));
       width: calc(anchor-size(width) - var(--off) * 2);
       height: calc(100% - var(--off) * 2);
       border-radius: inherit; background: var(--tk-color-accent);
       transition: left .25s ease-out, width .25s ease-out;
       pointer-events: none;
     }
     .menu:has(a:is(:hover, :focus-visible))::before {
       position-anchor: --nav-hover;
     }
   }
3. Fallback sem suporte (Safari/Firefox antigos): dentro de um
   @supports not, o link ativo ganha a pastilha como background
   próprio (sem deslize). O menu permanece 100% funcional.
4. aria-current="page" no link ativo (via usePathname) é o que
   posiciona a pastilha em repouso: mantenha-o correto por rota.
5. O deslize também acompanha o foco por teclado (focus-visible no
   seletor), de graça.

Passo 5 — O padrão de acessibilidade: menu que floresce em círculo (Motion 5)

Tocar em "Menu" faz a tela florescer: um círculo de cor nasce do centro e se expande até cobrir tudo; uma fração de segundo atrás, um segundo círculo repete a onda — efeito de gota na água — enquanto o conteúdo cresce de dentro. Fechar inverte a ondulação.

O motion é CSS puro (clip-path: circle() em duas ondas via transition-delay). Mas o motivo de este passo vir cedo é outro: ele estabelece o padrão de acessibilidade de overlays que o site auditado executa de forma exemplar — inert no overlay fechado, aria-expanded, Escape, foco devolvido ao gatilho. É o custo mínimo de um overlay sério, e você vai reaproveitar a ergonomia em tudo.

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

1. Estrutura: button[data-menu-trigger] (no header) +
   div.overlay (fixed inset-0 z-50, background cor-primária,
   clip-path: circle(0% at 50% 50%), transition: clip-path .3s linear)
   > div.overlay-outer (mesmo clip e transition, com
   transition-delay: .25s) > div.overlay-inner (height: 100dvh,
   scale: 0, transition: scale .3s .25s) com a navegação.
2. Estado aberto (classe 'overlay-open' no <html>):
   .overlay-open .overlay,
   .overlay-open .overlay-outer { clip-path: circle(80% at 50% 50%); }
   .overlay-open .overlay-inner { scale: 1; }
   .overlay-open { overflow: clip; }   /* trava o scroll nativo */
3. Toggle acessível (replique fielmente):
   - aria-expanded no botão; rótulo alterna "Menu"/"Close" (textos
     vindos de props/dicionário);
   - o overlay recebe o atributo inert quando FECHADO (foco nunca
     entra nele invisível) e perde o inert ao abrir;
   - Escape fecha; ao fechar, o foco volta ao botão; um "blur guard"
     no último elemento focável devolve o foco ao botão (loop).
4. Links do menu grandes; ao navegar, feche antes do push.
5. prefers-reduced-motion: troque os circles por um fade simples
   (transition de opacity), mantendo toda a ergonomia.

Passo 6 — O scrub mais sofisticado: texto "tinta sobre o rascunho" (Motion 7)

Um texto grande está na página em tom apagado, como um rascunho a lápis. Conforme você rola, uma versão "a tinta" vai sendo escrita por cima, linha por linha — a primeira linha se preenche da esquerda para a direita, depois a segunda, sempre no ritmo exato da rolagem. Voltou a rolagem, a tinta "desescreve". É ler com os olhos e com o dedo ao mesmo tempo.

A técnica mais elegante do catálogo: SplitText divide em linhas; para cada linha o JS injeta um clone do próprio conteúdo com cor cheia e clip-path: inset(0 100% 0 0); uma timeline com scrub anima o clip de cada clone com stagger igual à duration — o segredo aritmético que faz as linhas se preencherem em sequência perfeita, sem sobreposição nem buraco. Este passo também valida os gates de fonte e o re-split por ResizeObserver, que você vai precisar em qualquer trabalho sério com SplitText.

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

1. Estrutura: div[data-ink-text] com o(s) parágrafo(s). CSS:
   o texto base em cor apagada (--tk-color-dim); 
   .line { position: relative; }
   .line-ink { position: absolute; top: 0; left: 0; width: 100%;
     pointer-events: none; z-index: 1; color: var(--tk-color-ink);
     clip-path: inset(0 100% 0 0); }
2. Pipeline (useGSAP), SÓ depois de useFontsReady(familia):
   let split, tl;
   const build = () => {
     tl?.revert(); split?.revert();
     split = SplitText.create(els, { type: 'lines',
       linesClass: 'line',
       onSplit: (self) => {
         self.lines.forEach((line) => {
           line.innerHTML += `<span class="line-ink" aria-hidden="true">
             ${line.innerHTML}</span>`;
         });
         tl = gsap.timeline({ scrollTrigger: {
           trigger: rootRef.current,
           start: 'top 80%', end: 'bottom 40%', scrub: 0.8 } })
         .to(rootRef.current.querySelectorAll('.line-ink'), {
           clipPath: 'inset(0 0% 0 0)', duration: 1.5,
           ease: 'none', stagger: 1.5 });
       } });   // stagger === duration ⇒ linhas em sequência exata,
   };          // sem sobreposição nem buraco
   build();
   new ResizeObserver(build).observe(rootRef.current);
   (cleanup: revert de split e tl + disconnect do observer)
3. Acessibilidade: os clones têm aria-hidden (o texto base já é
   lido); nada de aria duplicado.
4. prefers-reduced-motion: sem split; renderize o texto direto na
   cor cheia.

Passo 7 — Scrubs declarativos em série: parallax e morph circular

Com o padrão de scrub validado, dois efeitos saem quase de graça.

Motion 10 — Parallax declarativo por data-attributes

Certas imagens derivam suavemente enquanto a página rola, dando profundidade de camadas ao layout. O padrão mais enxuto possível: data-parallax no elemento, data-offset opcional, e um hook varre tudo com um tween de scrub. O gate de breakpoint e o de reduced motion moram na própria query do matchMedia — elegância pura.

txt
Crie um hook client "useParallax(scopeRef)" em Next.js:

1. Dentro de useGSAP({ scope }), com mm = gsap.matchMedia():
   mm.add('(min-width: 980px)', () => {
     gsap.utils.toArray('[data-parallax]', scope).forEach((el) => {
       gsap.to(el, {
         y: el.dataset.offset || '10%',
         ease: 'none',
         scrollTrigger: { trigger: el, start: 'top bottom',
           end: 'bottom top', scrub: 1 }
       });
     });
   });
2. Uso declarativo no markup:
   <figure data-parallax data-offset="14%">…</figure>
   Offsets negativos ("-8%") derivam no sentido contrário.
3. A moldura externa deve ter folga (overflow visível controlado ou
   bleed no layout) para a deriva não expor bordas vazias.
4. matchMedia já desmonta os triggers fora do breakpoint; reduced
   motion cai no mesmo gate (adicione a condição na query:
   '(min-width: 980px) and (prefers-reduced-motion: no-preference)').

Motion 9 — Morph de mídia para círculo

Uma fotografia retangular vive entre duas seções. Conforme a rolagem atravessa o trecho, a moldura se transforma continuamente num círculo perfeito — pontuação visual antes da página seguir. Um único tween: animar só o raio do inset (inset(0% round 50%)), mantendo as quatro bordas em 0%, morfa a forma sem mudar o tamanho.

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

1. Estrutura: div[data-circle-morph] (wrapper com aspect-ratio 1/1 ou
   controlado) > div[data-circle-item] (a mídia, overflow hidden,
   clip-path inicial: inset(0% round var(--tk-radius))).
2. useGSAP (gate: reduced motion desliga):
   gsap.to('[data-circle-item]', {
     scrollTrigger: { trigger: rootRef.current, scrub: 1,
       invalidateOnRefresh: true,
       start: 'top bottom', end: 'bottom bottom' },
     clipPath: 'inset(0% round 50%)', ease: 'none'
   });
   (animar apenas o raio do inset, mantendo 0% nas quatro bordas,
   dá o morph de retângulo → círculo sem mudar o tamanho)
3. Para círculo perfeito, o item precisa de aspecto 1:1 na faixa
   final; em wrappers largos, aceite a elipse (também elegante) ou
   anime também o inset lateral até quadrar.
4. Imagem com object-fit: cover e composição centrada (o corte
   circular final deve funcionar como enquadramento).

Passo 8 — Sticky com honestidade: cards empilhados com medição de encaixe (Motion 8)

Os cards de serviços não passam: eles se acumulam. O primeiro cola no topo, o segundo para um degrau abaixo, o terceiro um degrau abaixo do segundo — um leque de bordas visíveis, como cartas de baralho. E aqui está a lição deste motion: o JS não anima nada. O empilhamento é position: sticky + offsets em CSS; o JavaScript apenas mede se o efeito cabe (card mais alto × altura disponível) e liga ou desliga a classe. Não coube? Vira lista normal, sem pulos. É a medição honesta que separa sticky stacks premium de sticky stacks quebrados.

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

1. Estrutura: section[data-stacking] > ol > li.stack-item *N, cada um
   com article.card (conteúdo). O componente seta
   --stack-total: N no contêiner.
2. CSS (ativo apenas com .can-sticky no contêiner):
   .can-sticky { padding-bottom: calc(var(--stack-total) *
     var(--tk-stacking-gap)); margin-top: calc(var(--tk-stacking-gap)
     * -1); }
   .can-sticky .stack-item { position: sticky;
     top: calc(var(--tk-header-height)); }
   .can-sticky .stack-item:nth-child(1) { translate: 0
     calc(var(--tk-stacking-gap) * 1); }
   .can-sticky .stack-item:nth-child(2) { translate: 0
     calc(var(--tk-stacking-gap) * 2); }
   …generalize com um util ou style inline --i e
   translate: 0 calc(var(--tk-stacking-gap) * var(--i)).
   .card { height: var(--stack-card-height, auto); }
3. Medição (o coração do componente):
   - alturaDisponivel = viewport - (metade do padding do topo +
     altura do header + margem);
   - maiorCard = max(offsetHeight dos cards) com a var de altura
     REMOVIDA antes de medir;
   - se maiorCard < alturaDisponivel: seta
     --stack-card-height: maiorCard px em todos (alturas iguais dão
     o leque perfeito) e liga .can-sticky; senão desliga tudo.
4. Gates: useMediaQuery('(min-width: 1440px)') liga/desliga um
   ResizeObserver que roda a medição; usePrefersReducedMotion()
   true ⇒ componente nem inicializa (lista estática).
5. Sem ScrollTrigger: sticky nativo faz o empilhamento sozinho.

Passo 9 — O marquee que obedece à sua rolagem (Motion 6)

Uma faixa de palavras desliza continuamente pela tela — e o detalhe que a torna viva: ela obedece à sua rolagem. Rolando para baixo, corre para a esquerda; inverteu a rolagem, a faixa inverte junto, como uma esteira engatada no seu gesto. Fora da tela, para de gastar energia.

Nada de keyframes: um ticker GSAP move x com velocidade compensada por deltaRatio() — o detalhe que impede o marquee de rodar 2x mais rápido em monitores 120Hz. Teste exatamente isso.

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

1. Props: speed (default 0.8), reverse (default false), children
   (itens da faixa). Estrutura: div.marquee (overflow hidden) >
   div.track > div.group (os itens); o componente clona o group
   para preencher: se group.offsetWidth < viewport, clone os itens
   internos ceil(viewport/width) - 1 vezes; depois anexe DUAS cópias
   do group ao track. Guarde: groupW, trackW e os limites de wrap
   (min = -(trackW - viewport), reset = -(groupW - viewport),
   max = 0, resetUp = -groupW * 2).
2. Movimento (useGSAP):
   const setX = gsap.quickSetter(track, 'x', 'px');
   let x = -track.offsetWidth * 0.5;   // meio do percurso: folga
                                       // para os dois sentidos
   const tick = () => {
     const step = (1.5 - Math.pow(0.8, gsap.ticker.deltaRatio()))
       * speed;                        // compensação de FPS
     const movingLeft = (scrollDir === 'down') !== reverse;
     if (movingLeft) { if (x <= min) x = reset; x -= step; }
     else            { if (x >= 0)  x = resetUp; x += step; }
     setX(x);
   };
3. Direção: listener de scroll passive comparando window.scrollY com
   o anterior → scrollDir 'down' | 'up'.
4. IntersectionObserver no componente: entrou → gsap.ticker.add(tick);
   saiu → gsap.ticker.remove(tick). Cleanup remove tudo.
5. Resize: debounce 250ms → re-clona e re-mede (remova clones antigos
   antes).
6. Acessibilidade: aria-hidden na faixa + versão sr-only do conteúdo;
   prefers-reduced-motion: renderize a faixa estática (sem clones nem
   ticker: o site auditado faz exatamente isso).

Passo 10 — O conjunto do portfólio: perseguidor de cursor e grades responsivas

Motion 11 — Perseguidor de cursor com selo giratório

Ao entrar com o mouse num card de projeto, um selo redondo se materializa exatamente onde o cursor está e passa a persegui-lo com atraso elástico por dentro do card. No selo, uma frase-convite gira em círculo. Saiu do card, o selo se recolhe onde estiver.

O truque de nascimento: no primeiro movimento, passe a coordenada duas vezes ao quickTo — isso seta from e to juntos e o selo nasce sob o cursor em vez de viajar do canto. É um caractere de código que muda completamente a sensação.

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

1. Estrutura: article.card (position relative, [data-chaser-parent])
   > mídia/conteúdo + div.badge ([data-chaser], position absolute
   top-0 left-0, pointer-events none) > div.badge-text (SVG textPath
   circular, animation: badge-spin 7s linear infinite, scale: 0,
   transition: scale .2s ease-out) + div.badge-core (rótulo/seta
   central, scale 0, transition .2s).
   .card.active .badge-text, .card.active .badge-core { scale: 1; }
2. Perseguição (useGSAP; gates: matchMedia('(hover: hover)') e
   reduced motion false):
   const toX = gsap.quickTo(badge, 'x', { duration: .4,
     ease: 'power3' });
   const toY = gsap.quickTo(badge, 'y', { duration: .4,
     ease: 'power3' });
   let first = true;
   const move = (e) => {
     const r = card.getBoundingClientRect();
     const x = e.clientX - r.left, y = e.clientY - r.top;
     if (first) { toX(x, x); toY(y, y); first = false; }
     else { toX(x); toY(y); }
   };   // (passar o valor 2x seta from e to: o selo NASCE sob o
        //  cursor em vez de viajar do canto)
   pointerover → card.classList.add('active') +
     card.addEventListener('pointermove', move);
   pointerout → remove classe e listener; first = true.
3. O card inteiro é um link; o selo é decorativo (aria-hidden).
   Foco por teclado mostra o selo centrado (classe active via
   focus-within, sem perseguição).
4. Em touch: nada disso monta; o card funciona como link comum.

Motion 14 — Grades responsivas: masonry alternada e tiles fundidos

Dois utilitários de composição que parecem mágica de layout: o portfólio em duas colunas desencontradas com ordem alternada garantida (1º na esquerda, 2º na direita...), e fileiras de blocos cujas bordas de 1px se fundem — vizinhos "compartilham" o contorno sem nunca duplicar a espessura, qualquer que seja a quebra de linha no resize.

txt
Crie dois utilitários client em Next.js:

1. "MasonryTwo": recebe children (cards);
   - refs para col-a e col-b (grid de 2 colunas em >= 1150px, 1 no
     mobile);
   - distribute(matches): se desktop, item i vai para a coluna i % 2;
     senão todos para a col-a; ao final, classe 'is-ready' no root
     (o CSS mantém opacity 0 até is-ready para não piscar a
     redistribuição);
   - matchMedia('(min-width: 1150px)') com listener 'change'
     redistribui. (Alternativa CSS-first: columns/grid-template-rows
     masonry quando o suporte chegar; a distribuição JS garante a
     ORDEM alternada, que CSS columns não dá.)
2. "FusedTiles": recebe children (tiles com border: 1px solid);
   - cada tile: width: calc(100% + 1px) e
     translate: 0 calc(var(--tile-row) * -1px);
   - assignRows(): agrupa os tiles por Math.round(rect.y), atribui
     --tile-row = índiceDaLinha a cada um (linha 0 → 0, linha 1 → 1,
     …) via style.setProperty;
   - ResizeObserver no contêiner roda assignRows (as quebras mudam
     com a viewport).
   - Resultado: bordas verticais e horizontais fundidas em 1px
     contínuo, qualquer que seja a quebra.
3. Ambos sem animação de scroll: são utilitários de composição que
   os motions de reveal (quando houver) apenas herdam.

Passo 11 — O fechamento: depoimentos em loop e o "tocando agora"

Motion 13 — Slider de depoimentos em loop

Depoimentos numa fileira contínua de cartões de larguras naturais (cada citação com o tamanho que pede), arrastável, em loop sem fim. E uma decisão editorial que vale registrar: sem autoplay — depoimentos pedem leitura no tempo do usuário, e o site auditado respeita isso.

txt
Crie um componente client "TestimonialLoop" em Next.js com Embla:

1. useEmblaCarousel({ loop: true, align: 'start',
   containScroll: false }). Slides: flex 0 0 auto, width max-content
   com max-width (ex. 36rem), gap ~50px via margens.
2. Cursor: classe grab/grabbing no viewport durante o arraste
   (embla expõe pointerDown/Up via eventos).
3. Setas prev/next (podem usar o CircleSwapButton do Motion 12 em
   versão compacta); com loop: true não há extremidades: as setas
   nunca desabilitam.
4. Acessibilidade: region com aria-roledescription="carousel",
   slides com role="group" e rótulos "N de M"; anuncie mudanças em
   aria-live="polite".
5. Autoplay: NÃO incluir (o site auditado não usa; depoimentos pedem
   leitura no tempo do usuário).
6. prefers-reduced-motion: mantenha o slider (é controlado pelo
   usuário), apenas reduza duration do snap se configurável.

Motion 15 — Widget "tocando agora"

No rodapé, uma linha diz o que está tocando no estúdio: faixa, artista e um pontinho pulsando no ritmo de quem respira — o sinal universal de "ao vivo". Ao pairar no link, o ícone gira 45° com uma leve encolhida, como um botão de toca-discos sendo tocado. Minúsculo, memorável — e a lição final do site: o charme está na especificidade. Rótulos como "tocando no estúdio:" fazem o widget parecer gente, não feature.

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

1. Server Component com os dados ({ track, artist, url }) vindos de
   props ou de um fetch com revalidate (ex. 3600); fallback estático
   se a fonte falhar.
2. Markup: <p class="now-playing"> <span class="artist">…</span>
   <a class="track" href={url}>… <i>↗</i></a> </p>
3. CSS:
   .now-playing { display: inline-flex; align-items: baseline;
     gap: .5rem .875rem; flex-wrap: wrap; }
   .now-playing::before { content: ''; width: .625rem;
     aspect-ratio: 1; border-radius: 50%;
     background: var(--tk-color-accent); align-self: center;
     animation: live-pulse 1.4s ease-out infinite; }
   @keyframes live-pulse { 0%, 100% { scale: .4 } 50% { scale: 1 } }
   @media (hover: hover) {
     .now-playing:has(.track:is(:hover, :focus)) i {
       rotate: 45deg; scale: .9;
       transition: rotate .2s, scale .2s; } }
4. prefers-reduced-motion: animation: none no pulso (ponto estático).
5. O charme está na especificidade: rótulos como "tocando no
   estúdio:" fazem o widget parecer gente, não feature.

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

  • "use client" obrigatório em todo componente que toca GSAP 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.
  • Gates ANTES de inicializar, não depois: o padrão do site auditado é não montar a lógica quando prefers-reduced-motion: reduce, quando o breakpoint não comporta, ou quando (hover: hover) falha; replicar isso evita pagar custo de JS por efeitos que nunca vão rodar.
  • SplitText só depois das fontes (document.fonts.load da família usada, ou autoSplit); split com fallback font = quebras de linha erradas. E re-split no resize (revert + recreate via ResizeObserver), nunca reaproveitar linhas medidas noutra largura.
  • clip-path com raio nos DOIS estados: anime inset(... round r)inset(... round r) mantendo o round; omitir o raio num dos lados faz o canto "pular" no início do tween.
  • Config de scrollTrigger vs instância: passar o mesmo objeto de configuração a vários tweens é válido (cada um cria seu próprio trigger), mas uma instância já criada pertence a um único tween/timeline; na dúvida, use uma timeline única. E invalidateOnRefresh: true sempre que start/end forem funções.
  • ease: 'none' em todo scrub: a suavização vem do valor de scrub, nunca do ease.
  • Ticker com deltaRatio(): qualquer movimento por ticker (marquee) deve compensar o framerate, ou ficará 2x mais rápido em monitores 120Hz.
  • Pause fora do viewport: todo trabalho contínuo liga/desliga por IntersectionObserver.
  • Anchor Positioning é progressive enhancement: embrulhe em @supports (anchor-name: --a) com fallback estático digno; o mesmo vale para :has() em navegadores antigos.
  • inert no overlay fechado e aria-expanded no gatilho: o menu invisível não pode receber foco; Escape fecha; foco retorna ao gatilho. É o custo mínimo de um overlay sério.
  • Sticky stack precisa de medição honesta: cards mais altos que o espaço disponível com sticky ligado criam "pulos"; meça e desligue o efeito quando não couber, como o site auditado faz.
  • translate/scale/rotate como propriedades individuais (CSS moderno) não se misturam com transform no mesmo elemento sem cuidado: escolha um dos dois mundos por elemento (o GSAP escreve em transform).
  • ScrollTrigger.refresh() após load de imagens que mudam layout; scrubs com start posicional dependem do layout final.
  • prefers-reduced-motion em tudo: hero direto no estado final, marquee estático, stacking vira lista, texto a tinta já "escrito", pulsos desligados.

Por onde começar hoje

O trio de entrada: Passo 1 (a infra com os três gates — é ela que muda sua forma de escrever componentes de motion), Passo 2 (botão e selo em CSS puro — resultado imediato) e Passo 4 (a nav pill com Anchor Positioning — meia hora que vai te ensinar mais CSS moderno que um mês de tutoriais).

E com este guia a série fecha um triângulo instrutivo: a Plantica prova que CSS com variáveis substitui quase todo tween; a ANAI mostra como camadas separam o contínuo, o evento e o espetáculo; e o Studio Modular ensina a disciplina que faz tudo escalar — módulos autocontidos, gates na porta, e o CSS moderno resolvendo antes do JavaScript ser convocado. Três sites, três filosofias, um repertório: agora é escolher a do próximo projeto.

Leia também