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

Sumário
- A filosofia: módulos que sabem quando não rodar
- A stack e o papel de cada peça
- O mapa dos 15 motions
- Passo 1 — A fundação: infraestrutura e os três gates
- Passo 2 — Vitórias rápidas em CSS puro: o botão e o selo de scroll
- Passo 3 — O cartão de visitas: wordmark bipartido que se monta e se desmonta
- Passo 4 — A joia técnica: nav pill com CSS Anchor Positioning (Motion 4)
- Passo 5 — O padrão de acessibilidade: menu que floresce em círculo (Motion 5)
- Passo 6 — O scrub mais sofisticado: texto "tinta sobre o rascunho" (Motion 7)
- Passo 7 — Scrubs declarativos em série: parallax e morph circular
- Passo 8 — Sticky com honestidade: cards empilhados com medição de encaixe (Motion 8)
- Passo 9 — O marquee que obedece à sua rolagem (Motion 6)
- Passo 10 — O conjunto do portfólio: perseguidor de cursor e grades responsivas
- Passo 11 — O fechamento: depoimentos em loop e o "tocando agora"
- As 15 armadilhas que vão te morder (leia antes de codar)
- 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: reduceestá 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
| Biblioteca | Papel no projeto | Por quê |
|---|---|---|
| GSAP 3.13+ e @gsap/react | Timelines de entrada do hero, scrubs (texto, wordmark, morph, parallax), ticker do marquee, quickTo do perseguidor | GSAP é 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 loop | O site auditado usa Swiper (slidesPerView auto, loop); Embla é o equivalente idiomático em React |
| CSS moderno | Anchor Positioning (nav pill), clip-path: circle() (menu), :has() (hovers compostos), backdrop-filter | Boa 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
| # | Motion | Uso típico | Gatilho |
|---|---|---|---|
| 1 | Abertura do hero: wordmark bipartido + mídias que brotam | Hero da home | Load |
| 2 | Separação do wordmark no scroll | Hero da home | Scroll |
| 3 | Selo "scroll" com texto circular giratório | Hero | Loop automático |
| 4 | Nav pill deslizante (Anchor Positioning) | Header desktop | Hover |
| 5 | Menu overlay que floresce em círculo | Menu mobile/global | Clique |
| 6 | Marquee sensível à direção do scroll | Faixas de texto/logos | Loop + Scroll |
| 7 | Texto scrub "tinta sobre o rascunho" | Manifesto/intro longa | Scroll |
| 8 | Cards empilhados sticky com medição de encaixe | Lista de serviços/planos | Scroll |
| 9 | Morph de mídia para círculo | Divisor de seções | Scroll |
| 10 | Parallax declarativo por data-attributes | Imagens editoriais | Scroll |
| 11 | Perseguidor de cursor com selo giratório | Cards de projeto | Hover |
| 12 | Botão com círculo que troca de lado | CTAs globais | Hover |
| 13 | Slider de depoimentos em loop | Prova social | Arraste/Setas |
| 14 | Grades responsivas: masonry alternada + tiles fundidos | Portfólio e listas de links | Resize |
| 15 | Widget "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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 deuseEffectcru: ocontext.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.loadda família usada, ouautoSplit); split com fallback font = quebras de linha erradas. E re-split no resize (revert + recreate via ResizeObserver), nunca reaproveitar linhas medidas noutra largura. clip-pathcom raio nos DOIS estados: animeinset(... round r)→inset(... round r)mantendo oround; omitir o raio num dos lados faz o canto "pular" no início do tween.- Config de
scrollTriggervs 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. EinvalidateOnRefresh: truesempre questart/endforem funções. ease: 'none'em todo scrub: a suavização vem do valor descrub, 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. inertno overlay fechado earia-expandedno 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/rotatecomo propriedades individuais (CSS moderno) não se misturam comtransformno mesmo elemento sem cuidado: escolha um dos dois mundos por elemento (o GSAP escreve emtransform).ScrollTrigger.refresh()após load de imagens que mudam layout; scrubs comstartposicional dependem do layout final.prefers-reduced-motionem 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
Motions premium em Next.js: os 18 padrões do site da Plantica, passo a passo
O scroll vira CSS custom property e o CSS faz o resto: os 18 padrões de motion do site da Plantica em Next.js, com o prompt de implementação pronto de cada um.
- motion
- gsap
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
Motions premium em Next.js: os 18 padrões do site da ANAI, passo a passo
A arquitetura híbrida do site da ANAI — CSS vars, tweens por seção e canvas — destrinchada em 18 padrões de motion com prompts prontos.
- motion
- gsap


