Conteúdo deste artigo
- Visão geral técnica
- A seção do herói
- Revelação do título do herói
- Símbolo do Herói: Estado Inativo
- Símbolo do Herói: Flutuação Magnética
- Hero Lines: Efeito de faísca de solda
- Símbolo do Herói: Segurar para Explodir
- Sequência de rolagem de serviços
- Um driver de rolagem compartilhado
- A sequência de imagens
- Explosão de partículas no título
- Animação do cartão de serviço
- Transição de limpeza de listras
- Galeria Dribbble Helix
- Construindo a Hélice
- Dobrando cartas na hélice
- Renderização baseada em rolagem
- Interação Hover baseada em Raycast
- Cantos arredondados com um Fragment Shader
- Aquecendo a cena
- Logotipo do fio de rodapé e fumaça
- Cada traço SVG se torna uma string
- A onda é desenhada processualmente
- Ampliando a área de acerto
- Sintetizando o som de arrancada
- Névoa processual com um shader de fragmento
- Névoa reativa de áudio
- Leão revelado e cortina arrastada
- Sequenciando a revelação
- Retrato mapeado em profundidade
- Simulando a cortina
- Desenhando a cortina
- Som impulsionado por movimento
- Parede de dispersão da galeria
- Posições aleatórias de slots predefinidos
- Animação de entrada multidirecional
- Sequência de entrada controlada por rolagem
- Extração de cores dominantes de imagens
- Movimento fotográfico interativo com resposta a colisão
- Montagem de imagem do lado do cliente e amostragem de cores
- Notas de encerramento
Trionn foi construído como uma exploração de até onde um site de estúdio pode ser levado quando animação, WebGL e sistemas de interação são tratados como uma experiência unificada. Após meses de experimentação e iteração, o resultado final combina GSAP, Three.js, Lenis e interações personalizadas de Web Audio em uma experiência digital responsiva, onde cada seção é conduzida por seu próprio sistema cuidadosamente elaborado.
O projeto evoluiu através de vários conceitos antes de chegar ao seu rumo final. O herói interativo, a narrativa baseada em rolagem, os gráficos procedurais e os efeitos em tempo real não foram planejados como recursos isolados, mas gradualmente desenvolvidos em uma estrutura de animação conectada.
Construído ao longo de quatro meses, o site recebeu reconhecimento da FWA, GSAP, Orpetron, CSS Design Awards, Web Design Awards, CSS Winner e diversas galerias de design internacionais. Mais importante ainda, o processo revelou uma série de desafios técnicos em torno de desempenho, sincronização, renderização e design de interação.
Neste estudo de caso, exploraremos a arquitetura por trás do Trionn, detalhando os sistemas de animação, técnicas WebGL, estratégias de otimização e padrões de código usados para dar vida à experiência.
Visão geral técnica
Construir um site com esse nível de animação significou equilibrar flexibilidade criativa com desempenho. Cada parte da pilha tem uma responsabilidade clara, desde a condução de animações e interações de rolagem até a renderização de cenas WebGL e a geração de áudio em tempo real.
As principais tecnologias usadas em todo o projeto são:
- GSAP + @gsap/react para linhas do tempo, transições de página e animações em nível de componente.
- ScrollTrigger para revelações controladas por rolagem, seções fixadas e sequências limpas.
- SplitText para animações de texto reutilizáveis baseadas em caracteres, palavras e linhas.
- Three.js para o símbolo do herói, a seção Serviços e a grade de trabalho interativa.
- Lenis para rolagem suave, sincronizada com GSAP.
- Web Audio API para gerar efeitos sonoros interativos em tempo de execução.
- Next.js e React como a estrutura do aplicativo.
- Tailwind CSS para estilização.
- Swiper pelos depoimentos e carrosséis de prêmios.
GSAP fica no centro do sistema de animação. Transições de página, sequências orientadas por rolagem, seções fixadas e animações em nível de componente são todos construídos em torno de cronogramas GSAP e gerenciados com ###CODE_0e6330e370823bedaa0447455ee3d54b###, que simplifica a configuração e a limpeza conforme os componentes são montados e desmontados em Next.js.
ScrollTrigger lida com a maioria das interações de rolagem do site, desde seções de narrativa fixadas até animações limpas e efeitos de revelação. Também confiamos muito em gsap.matchMedia() para que os layouts de desktop e dispositivos móveis possam ter sua própria lógica de animação em vez de compartilhar os mesmos valores.
Para animação de texto, construímos um componente reutilizável BlurTextReveal usando SplitText. Ele oferece suporte a animações baseadas em caracteres, palavras e linhas, ao mesmo tempo que centraliza o manuseio de movimento reduzido, a limpeza da camada de GPU e as atualizações do ScrollTrigger, em vez de resolver esses problemas para cada título individual.
Three.js potencializa as experiências WebGL personalizadas do site. Optamos por não usar o React Three Fiber porque queríamos controle direto sobre o loop de renderização compartilhado, o gerenciamento de recursos e os painéis de malha animados individualmente do símbolo do herói.
Lenis é conduzido diretamente de gsap.ticker, mantendo a rolagem sincronizada com ScrollTrigger em todo o site.
Efeitos sonoros interativos, incluindo efeitos de passagem do herói, explosão e soldagem, são gerados em tempo de execução com a Web Audio API em vez de usar arquivos de áudio pré-gravados.
A seção do herói
O herói evoluiu consideravelmente ao longo do projeto. Ele combina WebGL, GSAP, SplitText e Web Audio API em um único sistema de interação, tornando-o uma das partes do site com maior envolvimento técnico.
O herói é construído a partir de duas camadas que compartilham o mesmo estado. O plano de fundo é uma única cena Three.js (useTrionnSymbolScene.ts) responsável pelo símbolo da marca, incluindo seu movimento ocioso, flutuação magnética, interação segurar para explodir e efeitos de faísca de solda. Todas essas interações contribuem para um único valor explodeAmt, que controla a distância entre os painéis do símbolo. Quer o usuário role, passe o mouse ou segure o botão do mouse, cada interação atualiza o mesmo valor, permitindo que as transições entre estados sejam suaves e contínuas.
O primeiro plano consiste em elementos DOM padrão – o título, a palavra rotativa e a dica de estatísticas – animados com GSAP e SplitText. O uso de HTML normal mantém o conteúdo acessível enquanto mix-blend-mode: difference garante que ele permaneça legível na tela WebGL.
Ambas as camadas são sincronizadas por meio de um sinalizador transitionReady compartilhado. As animações não começam até que a transição da página seja concluída, com o trabalho não crítico adiado usando requestIdleCallback para evitar competir com o carregamento inicial da página.
Revelação do título do herói
No carregamento da página, o título principal (“Projetado para”) é animado para exibição, um personagem por vez, usando uma transição escalonada de desfoque para nitidez. Em vez de um simples fade-in, cada personagem entra gradualmente em foco, criando uma introdução mais dinâmica à página.
// components/Sections/Home/Banner.tsx — usage
<BlurTextReveal
as="h1"
text="Designed to"
animationType="chars" // split per-character, not word/line
stagger={0.08}
delay={1.2} // waits for the page loader/transition to clear first
/>
// components/TextAnimation/BlurTextReveal.tsx — the engine behind it
const split = new SplitText(textRef.current, {
type: "chars, words, lines",
smartWrap: true,
});
const targets = split.chars; // animationType === "chars"
gsap.set([textRef.current, targets], {
autoAlpha: 0,
filter: "blur(12px)",
willChange: "filter, opacity", // promote to its own GPU layer only while animating
});
const tl = gsap.timeline({
paused: manual,
});
tl.to(textRef.current, {
autoAlpha: 1,
filter: "blur(0px)",
duration: 0.5,
}, delay)
.to(targets, {
autoAlpha: 1,
filter: "blur(0px)",
duration: 0.8,
stagger: {
each: 0.08,
from: "random",
}, // characters settle out of order
ease: "power2.out",
}, delay);
Usar filter: blur() junto com a opacidade cria a impressão de que o texto está entrando em foco, em vez de simplesmente desaparecer. Assim que a animação for concluída, will-change é removido para que o texto não ocupe mais sua própria camada GPU. O mesmo componente BlurTextReveal é reutilizado em todo o site para a palavra rotativa e a dica de estatísticas, com diferentes configurações de animação.
Símbolo do Herói: Estado Inativo
Quando inativo, o símbolo do herói gira continuamente enquanto cada um de seus três braços segue um movimento sutil de onda senoidal com um deslocamento de fase independente. Isso evita que a animação pareça perfeitamente sincronizada e dá ao símbolo uma sensação de movimento mais orgânica.
// hooks/useTrionnSymbolScene.ts — per-frame update loop
// Auto-rotate: a constant rotational drift, eased toward the mouse position
if (!st.dragging) {
st.rotY += prefersReducedMotion ? 0.0015 : 0.0042; // base spin speed
group.rotation.x +=
(st.rotX + mouse.y * 0.22 - group.rotation.x) * 0.06; // eased lerp
group.rotation.y +=
(st.rotY + mouse.x * 0.22 - group.rotation.y) * 0.06;
}
// Per-panel ambient drift — each of the 3 arms gets its own phase offset
// so the whole symbol doesn't breathe in lockstep
particles.forEach((p) => {
const phase = p.shapeIdx * (Math.PI * 2 / 3); // 0°, 120°, 240°
const armDriftX =
Math.sin(t * 0.4 + phase) * 0.012 * (1 - explodeAmt);
const armDriftY =
Math.cos(t * 0.35 + phase) * 0.008 * (1 - explodeAmt);
const armDriftZ =
Math.sin(t * 0.3 + phase * 1.5) * 0.006 * (1 - explodeAmt);
// ...position += drift, scaled down to 0 the moment any explode/hover state kicks in
});
Quando prefersReducedMotion está habilitado, a velocidade de rotação é reduzida em vez de ser desabilitada completamente. O movimento do mouse é aplicado por meio de interpolação linear (lerp), dando ao símbolo uma sensação suave e magnética em vez de corresponder diretamente ao cursor. O desvio do ambiente também é dimensionado por (1 - explodeAmt), permitindo que ele desapareça naturalmente à medida que outros estados de interação assumem o controle.
Símbolo do Herói: Flutuação Magnética
À medida que o cursor se move sobre o símbolo, o painel abaixo dele “carrega” brevemente, tornando-se mais brilhante e mais reflexivo, enquanto um bipe curto é reproduzido na primeira vez que o cursor entra naquele painel. A detecção de foco é realizada com raycasting em vez de CSS, permitindo que a interação siga a geometria 3D real do símbolo conforme ele gira.
// hooks/useTrionnSymbolScene.ts — hover detection via raycasting
const raycaster = new THREE.Raycaster();
// Per frame: only check for hover when the symbol is fully assembled
// (not mid-explode, not scrolled away, not in the intro animation)
if (
st.mouseScreenX !== -9999 &&
st.scrollProgress < 0.08 &&
st.clickBurst < 0.05 &&
st.introAmt < 0.08
) {
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(
particles
.filter((p) => !p.isEdge)
.map((p) => p.mesh as THREE.Mesh),
false,
);
const nowHit = hits.length > 0 ? hits[0].object : null;
if (nowHit !== st.hoveredMesh) {
if (nowHit) {
const hm = nowHit as THREE.Mesh & {
_flash?: number;
_flashActive?: boolean;
};
hm._flash = 1.0; // triggers the charge-up below
audio.playHoverBeep(); // only fires on a new panel, not every frame
}
}
}
// Elsewhere: decay the flash and ramp the material toward its "charged" look
mesh._flash = (mesh._flash || 0) * 0.92; // exponential decay each frame
const f = mesh._flash;
mat.envMapIntensity = 3.0 + f * 1.6; // brighter reflections
mat.transmission = 0.35 + f * 0.32; // more "glassy"
Raycasting contra a geometria do símbolo garante que o efeito de foco siga sua forma e rotação reais. O realce de cada painel decai independentemente usando um decaimento exponencial simples, evitando a sobrecarga de criar uma interpolação GSAP separada para cada malha.
Hero Lines: Efeito de faísca de solda
Três linhas-guia são animadas para fora do símbolo quando a página é carregada. Assim que a animação for concluída, passar o mouse sobre qualquer uma das linhas aciona uma pequena explosão de faíscas semelhantes a soldas que formam um arco em direção a uma ou duas das linhas restantes, reforçando a mensagem do herói: “Ouse ⚡ tocar nas linhas.”
// hooks/useTrionnSymbolScene.ts
// Sparks are only enabled once the guide lines have finished drawing
const baseLinesReadyForSpark =
inS1 &&
undrawAmt < 0.02 &&
st.lineState.every((s) => s.prog >= 0.995);
if (baseLinesReadyForSpark) {
// Hit-test the mouse against all 3 line paths (14px tolerance)
const allLinePts = [ptsL, ptsR, ptsB];
let hitResult: { x: number; y: number } | null = null;
let hitLineIdx = -1;
for (let li = 0; li < allLinePts.length; li++) {
const h = mouseNearLine(allLinePts[li], 14);
if (h) {
hitResult = h;
hitLineIdx = li;
break;
}
}
if (hitResult !== null) {
// New hover onto a line (not a continuous hold) → arm a short burst
if (!st.sparkHoverActive && st.sparkWasAway) {
st.sparkBurstLeft = 5 + Math.floor(Math.random() * 2); // 5–6 bolts per hover
}
if (st.weldCooldown <= 0 && st.sparkBurstLeft > 0) {
const wp = unproj2(hitResult.x, hitResult.y); // screen → world space
// Pick 1–2 other lines as targets
const otherIdxs = [0, 1, 2].filter((i) => i !== hitLineIdx);
const count = Math.random() > 0.5 ? 1 : 2;
const targetIdxs = otherIdxs
.sort(() => Math.random() - 0.5)
.slice(0, count);
const nearWpts = targetIdxs.map((li) => {
// Find the closest point on the target line to the hit position
const pts = allLinePts[li];
let bestPt: LinePt | null = null;
let bestD = Infinity;
for (const pt of pts) {
const dd =
(pt.x - hitResult!.x) ** 2 +
(pt.y - hitResult!.y) ** 2;
if (dd < bestD) {
bestD = dd;
bestPt = pt;
}
}
return unproj2(bestPt!.x, bestPt!.y);
});
triggerWeld(wp, nearWpts, !st.sparkSoundPlayed);
st.sparkBurstLeft--;
st.weldCooldown = 0.04 + Math.random() * 0.06; // throttle between bolts
}
}
}
Uma verificação de “pronto” garante que o efeito de solda só seja ativado quando todas as três linhas-guia terminarem o desenho. O sinalizador sparkWasAway aciona uma nova explosão somente quando o cursor entra em uma linha, evitando um fluxo contínuo de faíscas enquanto paira. Cada explosão varia ligeiramente, com um número aleatório de raios e linhas de alvo selecionadas aleatoriamente, de modo que a interação nunca ocorre exatamente da mesma maneira duas vezes.
A geração de bolt também é limitada pela taxa usando weldCooldown, que espaça cada bolt de 0,04 a 0,10 segundos, independentemente da taxa de quadros. O efeito de brilho é construído por camadas de geometrias THREE.Line, proporcionando a aparência desejada sem o custo de uma passagem de flores pós-processamento.
As próprias linhas de guia são renderizadas em um 2D fora da tela <canvas>, que é então usado como uma textura na cena Three.js. Isso permite que o efeito de solda execute testes de impacto 2D leves em relação às coordenadas do espaço da tela, em vez de projeção de raios em relação à geometria 3D. Como o efeito se comporta mais como um sistema de partículas do que como uma animação de UI, seu tempo é controlado por contadores simples em vez de cronogramas GSAP. Um som de faísca, sintetizado com a API de áudio da Web, é reproduzido uma vez por sequência, em vez de uma vez por parafuso, para evitar sobreposição de áudio.
Símbolo do Herói: Segurar para Explodir
Clicar e segurar o símbolo do herói aciona uma interação de vários estágios. Os elementos da interface próximos, incluindo a navegação e os títulos, começam a vibrar à medida que a carga aumenta. Depois de aproximadamente meio segundo, o símbolo se divide em seus painéis individuais, cada um seguindo sua própria trajetória e rotação enquanto uma explosão e um som “whoosh” sustentado são reproduzidos. Soltar o mouse inverte a sequência, juntando suavemente o símbolo novamente.
Pressione para baixo: inicie a carga
Quando o usuário pressiona o símbolo, a interação entra em um estado de carregamento zerando o temporizador e ativando o feedback de vibração inicial antes do início da sequência de explosão.
const onMouseDown = (e: MouseEvent) => {
// ...hit-test guard omitted...
};
window.addEventListener("mousedown", onMouseDown);
Carregar e explodir
Enquanto o botão do mouse é pressionado, a interação progride através de duas fases. Os primeiros 0,5 segundos são dedicados à animação de carregamento. Uma vez atingido esse limite, o símbolo se divide em seus painéis individuais, fazendo a transição para a sequência de explosão.
if (st.holding) {
st.holdTime += 1 / 60;
if (st.holdTime < 0.5) {
st.clickBurst = 0; // still charging
if (st.clickBurst === 0) {
// first frame past the threshold
audio.stopVibrateSound();
audio.playExplodeSound();
audio.startWooshSound();
}
st.vibrateAmt *= 0.88;
st.clickBurst = Math.min(1.0, st.clickBurst + 0.02); // ramps from 0 → 1 over ~50 frames
}
// Released — both values ease back down instead of snapping to 0
}
clickBurst Provoca a explosão
O valor clickBurst controla a distância que cada painel se move de sua posição original. À medida que aumenta de 0 para 1, cada painel segue sua própria direção e rotação predefinidas, criando o efeito de quebra do símbolo enquanto permanece totalmente determinístico.
const burstContrib =
st.scrollProgress < 0.15 ? st.clickBurst : 0;
const explodeAmt = Math.max(
st.scrollProgress,
st.hoverAmt,
burstContrib,
st.introAmt,
);
particles.forEach((p) => {
const amt = Math.max(0, explodeAmt - p.delay); // staggered by each panel's delay
const burst = amt * 5.5;
p.mesh.position.set(
p.explodeDir.x * burst + /* ...idle drift, mouse offset... */ 0,
p.explodeDir.y * burst,
p.explodeDir.z * burst,
);
p.mesh.rotation.x =
p.spinAxis.x * p.spinSpeed * amt * Math.PI;
});
Elementos de UI próximos reagem à carga
Enquanto o símbolo carrega, os elementos da interface próximos, incluindo a navegação e os títulos, usam o mesmo estado compartilhado para adicionar um efeito de vibração sutil. Quando a interação termina, eles retornam suavemente à posição de repouso usando transições CSS.
vibrateEls.forEach((el) => {
el.style.transition = "none";
el.style.transform = `translate(${sx}px, ${sy}px)`; // sx/sy from a sine wave
});
// On release:
el.style.transition =
"transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
el.style.transform =
"perspective(600px) translate(0px, 0px) rotateX(0deg)";
A recarga de 0,5 segundo introduz um atraso deliberado antes da explosão, fazendo com que a interação pareça intencional em vez de instantânea. Um único valor explodeAmt combina os efeitos de rolagem, pairar e a interação segurar para explodir usando Math.max(), permitindo que cada estado compartilhe a mesma lógica de animação. Como a interação é orientada por valores de estado em vez de interpolações, soltar o mouse em qualquer ponto reverte suavemente o efeito sem exigir um caminho de animação separado.
Sequência de rolagem de serviços
A seção Serviços é a sequência de rolagem mais complexa do site. Um único valor compartilhado scrollProgressRef (0–1) coordena cada parte da experiência: percorrer uma sequência de imagens WebP de 371 quadros, quebrar o título “NOSSOS SERVIÇOS” em partículas de glifo animadas, revelando os seis cartões de serviço ao longo de caminhos de movimento predefinidos, fazendo a transição da paleta de cores do site de preto para branco e terminando com um listra limpe na seção Depoimentos.
Um driver de rolagem compartilhado
Toda a sequência de serviços é orientada por um único valor normalizado scrollProgressRef variando de 0 a 1. Em vez de criar cronogramas separados para cada animação, intervalos de progresso individuais são derivados desse valor compartilhado para controlar a sequência de imagens, a animação do título, os cartões de serviço, as transições de cores e o final da seção. Essa abordagem mantém todas as partes da sequência sincronizadas, ao mesmo tempo que facilita o ajuste do tempo dos efeitos individuais.
// components/Sections/Home/TrionnServices.tsx
const TOTAL = 371;
const EXPLODE_START = 0.35;
const EXPLODE_END = 0.53;
const CARDS_START = 0.56;
const CARDS_END = 1.0;
// Inside the RAF loop:
const linear = scrollProgressRef.current; // 0 → 1, owned by the parent bridge
s.scrollT = mapServicesScrollProgress(linear, isMobile); // remapped for this section
const targetFrame = s.scrollT * (TOTAL - 1);
s.videoIdx += (targetFrame - s.videoIdx) * 0.12; // ease toward the target frame
drawFrame(s.videoIdx); // updates the <img> source
const inZone =
s.scrollT >= EXPLODE_START &&
s.scrollT <= EXPLODE_END;
const explodeT = inZone
? (s.scrollT - EXPLODE_START) / (EXPLODE_END - EXPLODE_START)
: 0;
if (inZone && s.gsapTL) {
s.gsapTL.progress(explodeT);
}
updateCards(s.cardsT); // cards use their own smoothed copy of scrollT
A sequência de imagens
A animação de fundo é uma sequência de 371 quadros WebP exibidos atualizando o src de um elemento <img> padrão. Em vez de renderizar vídeo ou usar <canvas>, essa abordagem mantém a implementação leve e ainda permite que a animação seja limpa diretamente pela posição de rolagem.
// drawFrame — update a single <img> instead of using <canvas> or <video>
const drawFrame = useCallback((i: number) => {
const el = imgRef.current;
const img = stateRef.current.imgs[Math.round(i)];
if (!img || !img.complete) return;
// Only update the DOM when the frame actually changes
if (el.src !== img.src) {
}
}, []);
// Preload all 371 frames in idle-time chunks of 20
const loadChunk = (start: number) => {
const end = Math.min(start + CHUNK, TOTAL);
for (let i = start; i < end; i++) {
const img = new Image();
// decode() avoids jank when the frame is first displayed
img.decode().then(checkChunkDone, checkChunkDone);
}
};
Explosão de partículas no título
O título “NOSSOS SERVIÇOS” é dividido em glifos individuais, cada um medido e animado de forma independente. À medida que o pergaminho atinge o ponto de transição, cada glifo segue sua própria trajetória, criando o efeito de quebra do texto antes que as cartas de serviço sejam introduzidas.
// Measure each character's on-screen position using the Range API.
// This matches the rendered layout, including kerning and line wrapping.
const measureChars = useCallback(() => {
overlay.querySelectorAll("[data-line]").forEach((line) => {
// ...
for (let i = 0; i < raw.length; i++) {
range.setStart(textNode, i);
range.setEnd(textNode, i + 1);
const r = range.getBoundingClientRect();
results.push({
ch: display[i],
x: r.left + r.width / 2,
y: r.top + r.height / 2,
/* font props */
});
}
});
return results;
}, []);
// Each measured character becomes its own <span>, preserving the original
// typography before being animated along an individual trajectory.
m.forEach((p, i) => {
const isHero = hi.has(i);
const angle = rand(-Math.PI, Math.PI);
const speed = isHero
? rand(0.05, 0.15) * maxDim
: rand(0.4, 0.9) * maxDim;
s.particles.push({
el,
ox: p.x,
oy: p.y,
dirX: Math.cos(angle),
dirY: Math.sin(angle) * rand(-1.0, 0.18),
speed,
/* ... */
});
});
Animação do cartão de serviço
À medida que as partículas do título se dispersam, os seis cartões de serviço são exibidos ao longo de caminhos curvos predefinidos. No desktop, os cartões são apresentados em pares esquerdo e direito, criando uma composição equilibrada e mantendo a sequência de rolagem fácil de seguir.
// Desktop: each pair starts 0.2 timeline units apart and follows a curved X path
const arc =
frac <= 0.5 ? Math.sin(frac * Math.PI) : 1;
const lX = lStartX + arc * (lPeakX - lStartX);
const lY = lStartY + frac * (lEndY - lStartY); // Y moves linearly from bottom to top
frames.push({
x: lX,
y: lY,
opacity: op,
});
// Once a pair reaches its center point, animate the SVG icon stroke
if (!s.svgFired.has(lk) && tlTime >= centerTime) {
s.svgFired.add(lk);
gsap.fromTo(
paths,
{
drawSVG: "0%",
},
{
drawSVG: "100%",
duration: 1.5,
stagger: 0.04,
},
);
}
Transição de limpeza de listras
A seção termina com uma transição de faixa que é reutilizada em todo o site, incluindo as seções Visão e Sobre. Usar o mesmo padrão de transição em diversas seções ajuda a manter a consistência visual e, ao mesmo tempo, mantém a implementação centralizada e reutilizável.
// `applyStripeHold` scrubs a paused stripe reveal timeline over the final
// portion of the section's scroll range, then slides the Testimonials section
// into view using a GPU-accelerated `yPercent` transform.
const holdT = Math.max(
0,
Math.min(1, (linear - holdStart) / (1 - holdStart)),
);
cache.tl.progress(holdT);
Ao contrário da maior parte do site, esta seção não usa ScrollTrigger. Cada animação é derivada de um único valor de progresso de rolagem que é recalculado em cada quadro, mantendo toda a sequência sincronizada sem coordenar múltiplas linhas de tempo.
Para evitar o bloqueio do carregamento inicial da página, os 371 frames WebP são pré-carregados em #requestIdleCallback lotes de 20, com img.decode() usado para preparar cada quadro antes de ser exibido. O movimento do cartão de serviço segue uma abordagem diferente: a linha do tempo GSAP é construída uma vez sempre que o layout muda e depois limpa por .progress() durante a rolagem, evitando o custo de recalcular a trajetória de cada cartão a cada quadro.
Galeria Dribbble Helix
A Galeria Double Helix combina elementos DOM e WebGL para criar uma sequência 3D orientada por rolagem. Nove cartões são organizados ao longo de uma hélice paramétrica que gira pela câmera conforme o usuário rola, enquanto duas linhas-guia animadas traçam a estrutura. As cartas respondem ao pairar através do raycasting, e a sequência termina com a hélice se desdobrando em uma grade plana, completa com máscara de cantos arredondados e um efeito cascata decadente.
Construindo a Hélice
O layout da galeria é gerado inteiramente com equações paramétricas em vez de um modelo 3D pré-construído. A posição e orientação de cada cartão são calculadas a partir de sua posição ao longo da hélice, tornando toda a estrutura processual e fácil de adaptar conforme o usuário rola.
// components/DribbleSection.tsx
const dip = (a: number) => {
const d = (a - MID) / DIP_S;
return DIP_A * Math.exp(-d * d); // Gaussian dip at the midpoint
};
const hPos = (a: number) =>
new THREE.Vector3(
R * Math.cos(a),
Y_START + a * pitchPerRad - dip(a), // rises steadily with a subtle midpoint dip
R * Math.sin(a),
);
Dobrando cartas na hélice
Em vez de posicionar malhas individuais ao redor da hélice, a geometria de cada cartão é deformada diretamente para seguir naturalmente a curva. Ao reescrever as posições dos vértices, cada cartão se dobra para corresponder ao formato da hélice, permanecendo como uma malha única, produzindo um resultado muito mais convincente do que simplesmente girar planos planos.
// wrapCardOnHelix — runs once per visible card, per frame
for (let col = 0; col < C; col++) {
const angle =
(sArcStart + (col / W_SEGS) * sArcWidth) / dsPerRad;
// Position each column along the helix using inline scalar math.
// Avoiding Vector3 allocations keeps the render loop free of GC pressure.
for (let row = 0; row < 2; row++) {
pos.setXYZ(
row * C + col,
cpX + ux * offsetAmt,
cpY + uy * offsetAmt,
cpZ + uz * offsetAmt,
);
}
}
pos.needsUpdate = true;
Renderização baseada em rolagem
A hélice não é renderizada em um loop de animação contínuo. Em vez disso, a renderização é conduzida diretamente pela posição de rolagem, com um ticker leve sendo executado apenas quando necessário para permitir que as interações e transições sejam resolvidas sem problemas. Isso mantém a resposta da cena, evitando trabalho desnecessário quando a hélice está em repouso.
const st = ScrollTrigger.create({
trigger: section,
start: "top top",
end: `+=${totalScroll}`,
pin: true,
onUpdate: () => {
renderTick(); // render immediately on every scroll update
syncTicker?.(); // determine whether the idle ticker should remain active
},
});
const isActive = () => {
const margin = window.innerHeight; // one viewport before and after the section
const viewTop = window.scrollY - margin;
const viewBottom = window.scrollY + window.innerHeight + margin;
return (
viewBottom > st.start &&
viewTop < st.start + totalScroll
);
};
syncTicker = () => {
isActive() ? startTicker() : stopTicker();
};
Interação Hover baseada em Raycast
Os cartões respondem ao foco usando raycasting Three.js em vez de eventos DOM. Em vez de mudar de tamanho instantaneamente, cada cartão desliza suavemente em direção a uma escala alvo, tornando a interação mais natural e preservando o movimento fluido da hélice.
if (pointerActive) {
raycaster.setFromCamera(pointer, cam);
const hits = raycaster.intersectObjects(
cards.filter((c) => c.visible),
false,
);
if (hits.length > 0) {
hoveredCard = hits[0].object as THREE.Mesh;
}
}
for (let k = 0; k < N; k++) {
const cur = cards[k].userData.hoverScale ?? 1.0;
const target = cards[k] === hoveredCard ? 1.12 : 1.0;
// Ease toward the target scale instead of snapping instantly
cards[k].userData.hoverScale =
cur + (target - cur) * hoverLerpK;
}
Cantos arredondados com um Fragment Shader
Em vez de depender de PNGs transparentes ou recursos de nove fatias, cada cartão usa um shader de fragmento leve para gerar cantos arredondados de maneira processual. Uma função de distância sinalizada mascara a imagem no shader, produzindo bordas nítidas em qualquer tamanho, mantendo a geometria simples e a renderização eficiente.
// Fragment shader — rounded corners for the gallery cards
vec2 q = abs(pxPos) - halfSize + uRadius;
float dist =
min(max(q.x, q.y), 0.0) +
length(max(q, 0.0)) -
uRadius;
float alpha = 1.0 - smoothstep(-0.5, 0.5, dist);
if (alpha <= 0.0) {
discard;
}
gl_FragColor = vec4(texture2D(map, vUv).rgb, alpha);
Aquecendo a cena
Para evitar problemas perceptíveis quando a seção é visualizada pela primeira vez, a cena WebGL é aquecida antes que o usuário a alcance. Texturas, sombreadores e geometria são renderizados com antecedência, garantindo que o primeiro quadro visível já esteja preparado e a experiência de rolagem permaneça suave.
// Shader compilation and texture upload normally happen on the first render.
// Warming up the scene ahead of time avoids that work landing on the first
// visible frame.
const warmUp = () => {
cards.forEach((m) => (m.visible = true));
renderer.compile(scene, cam); // compile all shaders
renderer.render(scene, cam); // upload textures and initialize GPU resources
cards.forEach((m, i) => {
});
renderer.clear(); // discard the warm-up frame
};
// Run once on initialization, then again after all textures have loaded.
warmUp();
A renderização é conduzida diretamente por ScrollTrigger atualizações em vez de um loop de animação em execução contínua. Um gsap.ticker só é inscrito enquanto a seção está dentro de uma janela de visualização da tela e ainda há animações se estabilizando, como atenuação de foco ou efeito cascata, reduzindo o trabalho desnecessário quando a cena está ociosa.
Para evitar problemas perceptíveis na primeira vez que a galeria aparece, a cena também é explicitamente aquecida. Os shaders são compilados e as texturas são carregadas na GPU antes que a seção se torne visível, transferindo o custo de inicialização único da primeira rolagem do usuário para a experiência.
Logotipo do fio de rodapé e fumaça
O rodapé combina SVG, Web Audio e WebGL em uma única experiência interativa. A marca wireframe se comporta como um conjunto de cordas de guitarra que podem ser tocadas com o cursor, produzindo notas sintetizadas e ondas animadas. Ao mesmo tempo, uma camada de fumaça WebGL separada reage ao sinal de áudio ao vivo, respondendo não apenas à interação do usuário, mas também ao conteúdo de frequência do próprio som.
Cada traço SVG se torna uma string
Cada traço da marca SVG é tratado como uma string independente com seu próprio estado de oscilação. Passar o mouse ou clicar injeta energia na corda, fazendo-a vibrar enquanto aciona uma nota sintetizada. Como cada caminho mantém seu próprio estado, múltiplas strings podem ser dedilhadas independentemente, permitindo interações sobrepostas sem interferir umas nas outras.
// components/Footer/TrionnFooterLogo.tsx
for (const p of paths) {
if (!hasStroke(p)) continue;
const ep = getEndpoints(p); // actual start/end points of the SVG path
const state: StringState = {
x1: ep.x1,
y1: ep.y1,
x2: ep.x2,
y2: ep.y2,
amp: 0,
phase: 0,
speed: 0,
cycles: 2.2,
note: DEFAULT_SCALE[i % DEFAULT_SCALE.length] * (i % 2 ? 1 : 0.5),
intensity:
i === 0
? 0
: Math.pow(i / (paths.length - 1), 1.25),
};
p.addEventListener("mouseenter", () => {
gsap.killTweensOf(state);
// Ease the string back to its resting state
gsap.to(state, {
amp: 0,
duration: 0.9,
ease: "expo.out",
});
gsap.to(state, {
speed: 0,
duration: 0.9,
ease: "expo.out",
});
pluckFluteDreamy(state.note, state.intensity);
pulseSmoke(0.4); // notify the fog layer
});
}
A onda é desenhada processualmente
Em vez de depender de uma animação CSS, a onda é recalculada e redesenhada a cada quadro. Isso torna possível controlar a amplitude, a frequência e o amortecimento de cada corda de forma independente, de modo que cada interação pareça responsiva e se comporte como um fio dedilhado em vez de uma animação em loop.
const makeWavePath = (
x1,
y1,
x2,
y2,
amp,
phase,
cycles,
) => {
const ux = (x2 - x1) / len;
const uy = (y2 - y1) / len; // unit vector along the string
const px = -uy;
const py = ux; // perpendicular ("wobble") direction
let d = `M ${x1} ${y1}`;
for (let i = 1; i <= 26; i++) {
const t = i / 26;
const env = Math.sin(Math.PI * t); // zero at the ends, strongest at the center
const wobble = Math.sin(
Math.PI * 2 * cycles * t + phase,
);
const x =
x1 +
(x2 - x1) * t +
px * wobble * amp * env;
const y =
y1 +
(y2 - y1) * t +
py * wobble * amp * env;
d += ` L ${x} ${y}`;
}
return d;
};
// Update only strings that are still moving.
if (st.amp > 0.02 || st.speed > 0.02) {
st.phase += st.speed * dt;
el.setAttribute(
"d",
makeWavePath(
st.x1,
st.y1,
st.x2,
st.y2,
st.amp,
st.phase,
st.cycles,
),
);
}
Ampliando a área de acerto
Como os traços SVG visíveis têm apenas alguns pixels de largura, interagir diretamente com eles seria frustrante. Em vez disso, cada string possui uma duplicata invisível com um traço muito mais grosso que é usado exclusivamente para eventos de ponteiro. Isso fornece uma área de acerto muito maior, mantendo a aparência visual do logotipo inalterada.
const clone = p.cloneNode(true) as SVGPathElement;
clone.setAttribute("stroke", "transparent");
clone.style.strokeWidth = `${Math.max(12, strokeWidth * 18)}`; // much wider than the visible stroke
clone.setAttribute("pointer-events", "stroke");
clone.addEventListener("mouseenter", () => {
p.dispatchEvent(
new Event("mouseenter", {
bubbles: true,
}),
);
});
p.parentNode?.insertBefore(clone, p.nextSibling);
Sintetizando o som de arrancada
Em vez de reproduzir áudio pré-gravado, cada dedilhação é sintetizada em tempo real usando a API de áudio da Web. Três osciladores de onda senoidal ligeiramente desafinados são colocados juntos e alimentados através de um atraso de feedback, produzindo um som suave, semelhante a uma flauta, que responde instantaneamente a cada interação.
const osc1 = ctx.createOscillator();
osc1.frequency.setValueAtTime(freq, now);
const osc2 = ctx.createOscillator();
osc2.frequency.setValueAtTime(freq * 2, now); // octave
const osc3 = ctx.createOscillator();
osc3.frequency.setValueAtTime(freq * 3, now); // harmonic
// Slow vibrato applied to the fundamental oscillator
const lfo = ctx.createOscillator();
lfo.frequency.setValueAtTime(4.9, now);
lfoGain.connect(osc1.frequency);
const delay = ctx.createDelay(1.0);
delay.delayTime.setValueAtTime(0.14, now);
const fb = ctx.createGain(); // feedback loop for the echo/reverb tail
fb.gain.linearRampToValueAtTime(
0.32 + intensity * 0.12,
now + 0.05,
);
delay.connect(echoLP);
echoLP.connect(fb);
fb.connect(delay);
Névoa processual com um shader de fragmento
O efeito de neblina é renderizado como um único shader de fragmento de tela inteira em vez de um sistema de partículas. O ruído fractal em camadas (FBM) cria o padrão básico, enquanto a distorção de domínio interrompe a repetição e dá à fumaça um fluxo mais orgânico. O resultado é um efeito leve que sobe continuamente e responde suavemente à interação do usuário.
// components/Footer/FooterFog.tsx — fragment shader
float fbm(vec2 p) {
// Fractal Brownian Motion: layered noise at progressively smaller scales
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 3; i++) {
v += a * vnoise(p);
p = p * 2.1 + vec2(3.7, 8.3);
a *= 0.5;
}
return v;
}
float rise = T * 0.07; // slow upward drift over time
vec2 q = vec2(
uv.x * aspect * 3.0,
(1.0 - y) * 4.5 + rise
);
float f = fbm(
q + 1.4 * fbm(q2 + ...)
+ ...
); // domain-warped noise creates organic, non-repeating smoke
// H is the hover energy injected by logo interactions.
vec3 col = mix(charcoal, ashGrey, pow(f, 1.0));
col = mix(col, lightGrey, pow(f, 2.2));
col = mix(col, hoverTint, H * 0.55 * f);
Névoa reativa de áudio
A névoa não responde simplesmente a eventos de foco. Em vez disso, ele ouve o mesmo gráfico de áudio usado para sintetizar os sons de arrancada por meio de um AnalyserNode ao vivo. Isso permite que o shader reaja ao conteúdo de frequência real do áudio, fazendo com que o movimento e a intensidade da fumaça reflitam o som que está sendo reproduzido, em vez de um simples gatilho liga/desliga.
// FooterFog reads from an AnalyserNode tapped off the logo's audio graph.
if (analyser && freqData) {
analyser.getByteFrequencyData(freqData);
let sum = 0;
// Measure the energy in the mid-frequency range.
for (let i = 2; i < freqData.length * 0.5; i++) {
sum += freqData[i];
}
const raw =
sum / (freqData.length * 0.5 * 255);
// Smooth the response for a more natural attack and decay.
freqEnergy +=
(raw - freqEnergy) * lerpFactor;
}
// Louder notes make the fog morph more quickly.
morphOffset +=
(4.0 + freqEnergy * 16.0 + hoverBoost * 2.8) * dt;
O som de arrancada é sintetizado inteiramente em tempo real usando a API de áudio da Web, combinando três osciladores, um vibrato LFO sutil e um atraso de feedback em vez de depender de áudio pré-gravado. Para tornar a interação fácil, cada traço SVG visível também possui uma duplicata invisível muito mais ampla que manipula eventos de ponteiro, permitindo que linhas finas permaneçam fáceis de passar e clicar.
O logotipo e a neblina permanecem vagamente acoplados por meio de um pequeno contexto de atmosfera compartilhada. O logotipo simplesmente expõe métodos como pulseSmoke() e getSmokeAnalyser(), enquanto o nevoeiro apenas reage aos dados que recebe. Não se sabe o que desencadeou o evento – apenas que está disponível nova energia – mantendo o efeito claramente separado e fácil de reutilizar noutro local.
Leão revelado e cortina arrastada
Esta seção combina uma imagem mapeada em profundidade com efeitos WebGL interativos para criar a ilusão de profundidade. Um retrato do leão responde ao movimento do cursor através de um shader de fragmento, enquanto tiras de cortina arrastáveis se abrem com física de primavera para revelar a imagem abaixo. Puxar a cortina aciona uma sequência sonora sincronizada, começando com o movimento do tecido e terminando com o rosnado de um leão.
Sequenciando a revelação
A animação do leão é atrasada intencionalmente até que a animação do título termine. Esperar a conclusão do texto cria um ritmo visual claro e garante que a revelação pareça uma continuação da história, em vez de competir pela atenção do usuário.
// components/Sections/About/AboutHero.tsx
useEffect(() => {
if (!earlyStart || introStartedRef.current) return;
// Reveal the headline first.
mainHeadingRef.current?.play();
const tLion = window.setTimeout(() => {
document.documentElement.dataset.trionnLionStart = "true";
new CustomEvent("trionn:about-lion-start"),
);
}, headingAnimMs);
// Subtitle and scroll hint follow shortly after.
return () => clearTimeout(tLion);
}, [earlyStart]);
// components/Sections/About/AboutLion.tsx
useEffect(() => {
const handler = () => setShouldInit(true);
if (
document.documentElement.dataset.trionnLionStart ===
"true"
) {
setShouldInit(true); // Event has already fired.
return;
}
"trionn:about-lion-start",
handler,
{ once: true },
);
return () => {
"trionn:about-lion-start",
handler,
);
};
}, []);
Retrato mapeado em profundidade
O retrato do leão cria a ilusão de profundidade usando um shader de fragmento único e um mapa de profundidade pré-computado em vez de geometria 3D real. À medida que o cursor se move, o shader desloca a imagem com base nas informações de profundidade armazenadas, produzindo um efeito de paralaxe convincente enquanto renderiza apenas um único plano texturizado.
// AboutLion.tsx — fragment shader
vec2 mouse = (uMouseEase - 0.5) * vec2(2.0, -2.0); // normalize to -1...1 (Y flipped)
float depth = texture2D(uDepth, contained).r; // precomputed depth map
float breathing =
sin(uTime * 0.0012) * 0.5 + 0.5; // subtle idle motion
float amount =
(0.03 + 0.012 * breathing) * uHover;
vec2 disp = mouse * depth * amount; // brighter pixels shift more
vec2 uv = contained - disp;
// Small RGB offset creates a subtle chromatic fringe.
float r = texture2D(uImage, uv + disp * 0.16).r;
float g = texture2D(uImage, uv).g;
float b = texture2D(uImage, uv - disp * 0.16).b;
gl_FragColor = vec4(
mix(bg, vec3(r, g, b), a),
1.0
);
// JavaScript: ease the cursor toward its target position.
state.eased.x +=
(state.mouse.x - state.eased.x) * 0.07;
state.eased.y +=
(state.mouse.y - state.eased.y) * 0.07;
gl.uniform2f(
uniforms.mouseEase,
state.eased.x,
state.eased.y,
);
Simulando a cortina
Cada faixa de cortina se comporta como uma simples mola que é atualizada a cada quadro. Quando uma tira é arrastada, ela retorna naturalmente à sua posição de repouso, enquanto as tiras circundantes são puxadas para preservar o formato da cortina. Isso cria a impressão de um pedaço contínuo de tecido, em vez de uma coleção de elementos independentes.
// physicsStep() — runs once per frame for every strip
const sp = -offY[i] * 0.12; // spring force pulling the strip back to rest
velY[i] = (velY[i] + sp) * 0.65; // apply damping
offY[i] += velY[i];
// While dragging, neighboring strips are pulled along to avoid
// visible gaps or overlaps, creating the impression of a continuous curtain.
if (dy > 0) {
for (let ii = 1; ii < stripCount; ii++) {
const overlap = aboveBot - thisRest;
if (overlap <= 0) break;
const sp = (overlap - offY[ni]) * 0.22;
velY[ni] = (velY[ni] + sp) * 0.62;
offY[ni] += velY[ni];
}
}
Desenhando a cortina
Cada faixa é desenhada processualmente em uma tela 2D em vez de usar uma imagem estática. A forma é distorcida por um envelope de arrasto gaussiano centrado no ponto de apoio do usuário, criando uma deformação suave que se espalha naturalmente pela tira e imita de perto o comportamento do tecido.
const updateDragEnvelope = () => {
const clickX = dragSeg / SEGS; // where along the strip you grabbed it
for (let s = 0; s <= SEGS; s++) {
const t = s / SEGS;
const dx = t - clickX;
dragEnvelope[s] = Math.exp(-(dx * dx) * SIGMA_INV); // Gaussian falloff from the grab point
}
};
// Each strip’s path bends most where you grabbed it, tapering off toward its ends:
const py = restY + displacement * dragEnvelope[s];
Som impulsionado por movimento
O áudio está vinculado diretamente à interação do usuário com a cortina. O efeito sonoro principal é repetido apenas enquanto um arrasto real está acontecendo, usando uma seção selecionada da gravação para criar uma textura contínua. Sons adicionais são acionados com base no progresso do movimento, enquanto cliques simples ou gestos incompletos permanecem silenciosos.
const startCurtainSound = () => {
// Loop just the middle section of the curtain SFX for as long as the drag continues
dragSource.start(0, dragStart);
};
const maybeTriggerSounds = () => {
if (!curtainStarted && dragSpeed > DRAG_MOVE_PX) {
startCurtainSound();
}
// Growl only fires once the curtain has been audibly open for CURTAIN_LEAD_S
// so it never plays on a quick tap, only a real pull.
if (
!growlStarted &&
curtainStarted &&
Math.abs(offY[dragStrip]) > CURTAIN_OPEN_PX &&
audioCtx.currentTime - curtainStartedAt >= CURTAIN_LEAD_S
) {
startGrowlSound();
}
};
// On release: only play the “thud” if something actually moved.
// A plain click should be silent.
if (!playedSomething) return;
Um CustomEvent (trionn:about-lion-start) conecta os dois componentes, acionando a sequência do leão somente depois que o título do herói termina de ser revelado. Ao contrário do símbolo do herói e do logotipo do rodapé, essa interação usa recursos de áudio gravados, como o movimento da cortina e o rugido do leão, em vez de som sintetizado. Os clipes são manipulados dinamicamente por meio de seções em loop, deslocamentos iniciais aleatórios e alterações de volume controladas pela velocidade de arrasto, criando uma experiência de áudio mais responsiva que evolui com a interação.
Parede de dispersão da galeria
O “Trabalhe duro. Toque alto”. A seção usa um layout de imagem dispersa, onde onze fotos da equipe são animadas em diferentes direções durante a rolagem. Cada imagem recebe uma posição aleatória a partir de um conjunto predefinido de slots seguros contra colisões, criando um layout que parece orgânico enquanto permanece controlado. Clicar em uma foto a traz para frente, ajusta as imagens ao redor para evitar sobreposição e extrai sua cor dominante do lado do cliente para influenciar o plano de fundo.
Posições aleatórias de slots predefinidos
O layout da galeria usa uma coleção fixa de posições em vez de coordenadas completamente aleatórias. Cada imagem recebe um slot embaralhado em tempo de execução, o que significa que a organização muda entre as visitas, mantendo cada foto dentro de uma área controlada e sem sobreposição. Pequenos deslocamentos de posição e valores de rotação são adicionados posteriormente para criar um efeito disperso mais natural.
// components/TrionnGallery/TrionnGallery.tsx
// Each image defines a desktop/mobile position fraction. The SET of
// positions is shuffled at runtime, so a given photo doesn’t always land
// in the same slot. This keeps the layout collision-free while still
// introducing variation.
const cells = IMAGES.map((img) =>
isMobile ? img.position.mobile : img.position.desktop
);
const shuffled = cells
.map((cell) => ({
cell,
sort: Math.random(),
}))
.sort((a, b) => a.sort - b.sort)
.map((x) => x.cell);
return items.map((item, index) => {
const rotation = randomBetween(-4, 4);
// Account for the rotated bounding box when calculating limits
const bounds = getRotatedBounds(
rect.width,
rect.height,
rotation
);
const cell = shuffled[index];
// Add small variations so the layout does not feel too rigid
const jitterX = randomBetween(-36, 36);
const jitterY = randomBetween(-36, 36);
return {
x: clamp(
cell[0] * vw - vw / 2 + jitterX,
minX,
maxX
),
y: clamp(
cell[1] * vh - vh / 2 + jitterY,
minY,
maxY
),
r: rotation,
s: 1,
};
});
Animação de entrada multidirecional
Em vez de mover todas as imagens na mesma direção, cada foto começa em uma das várias posições fora da tela antes de chegar ao seu local final. Isso cria uma revelação mais dinâmica onde a galeria parece estar se montando em torno do espectador.
const getStartPosition = (index: number) => {
const gap = Math.max(vw, vh) * 0.72;
const starts = [
{
x: -vw / 2 - gap,
y: -vh / 2 - 80,
}, // far off-screen, each corner/edge
{
x: vw / 2 + gap,
y: -vh / 2 + 30,
},
{
x: -80,
y: -vh / 2 - gap,
}, // from the top
{
x: 130,
y: vh / 2 + gap,
}, // from the bottom
// …10 total positions, cycled by index
];
return starts[index % starts.length];
};
Sequência de entrada controlada por rolagem
A revelação da galeria é controlada por uma linha do tempo de rolagem fixada que alterna a entrada de cada foto antes da transição para a limpeza final da faixa. Cada imagem é introduzida com um ligeiro atraso, criando uma composição sequencial, enquanto a segunda fase de animação é acionada somente após a galeria estar totalmente estabilizada.
const animationEnd = GALLERY_VH / (GALLERY_VH + STRIPE_HOLD_VH);
// Fraction of the pinned scroll duration reserved for the photo entrance
galleryTimeline = gsap.timeline({
scrollTrigger: {
trigger: section,
start: "top top",
end: `+=${GALLERY_VH + STRIPE_HOLD_VH}%`,
scrub: 0.6,
pin: true,
onUpdate: (self) => {
const holdT = Math.max(
0,
Math.min(
1,
(self.progress - animationEnd) / (1 - animationEnd)
)
);
// Stripes only start once the gallery entrance is complete
if (stripesTL) stripesTL.progress(holdT);
},
},
});
items.forEach((item, index) => {
galleryTimeline!.to(
item,
{
x: end.x,
y: end.y,
rotate: end.r,
duration: 1.2,
},
index * 0.34
);
});
Extração de cores dominantes de imagens
A imagem selecionada é analisada diretamente no navegador para determinar sua cor dominante sem exigir nenhum processamento externo. A imagem é primeiro reduzida a uma tela menor para uma amostragem mais rápida e, em seguida, os pixels são agrupados em grupos de cores para identificar os tons mais proeminentes, ignorando áreas transparentes, muito escuras, brilhantes ou de baixa saturação. A cor resultante é usada para tingir dinamicamente o fundo da galeria.
const getDominantImageColor = (img: HTMLImageElement): RGB => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 72; // downsample for speed
ctx.drawImage(img, 0, 0, 72, 72);
const pixels = ctx.getImageData(0, 0, 72, 72).data;
const buckets = new Map(); // quantize colors into coarse buckets
for (let i = 0; i < pixels.length; i += 4) {
const [r, g, b, a] = [
pixels[i],
pixels[i + 1],
pixels[i + 2],
pixels[i + 3],
];
if (a < 220) continue;
const brightness = (r + g + b) / 3;
const saturation = Math.max(r, g, b) - Math.min(r, g, b);
// Skip near-black, near-white, and gray areas
if (brightness < 25 || brightness > 245 || saturation < 12) {
continue;
}
const key = `${Math.round(r / 22) * 22},${Math.round(g / 22) * 22},${Math.round(b / 22) * 22}`;
// Accumulate count + a "vividness" score per bucket...
}
// Pick the bucket with the best count × score weighting,
// then average its raw pixels.
};
Movimento fotográfico interativo com resposta a colisão
Clicar em uma foto cria um efeito de avanço que a move brevemente em direção ao visualizador antes de retorná-la à sua posição original. Ao mesmo tempo, fotos sobrepostas próximas são detectadas e afastadas do ponto de interação, criando uma resposta física sutil que torna o layout disperso mais dinâmico.
const onClick = () => {
if (isCardAnimating) return;
isCardAnimating = true;
updateBackgroundTone(item);
moveOverlappingCards(item); // see below
gsap.timeline({
onComplete: () => {
resetInnerCard(item);
isCardAnimating = false;
},
})
.to(inner, {
x: pullX,
y: pullY,
rotation: direction * 4.5,
scale: 1.035,
opacity: 0.18,
duration: 0.34,
})
.set(item, {
zIndex: activeZIndex,
})
.to(inner, {
x: 0,
y: 0,
rotation: 0,
scale: 1,
opacity: 1,
duration: 0.56,
});
};
const moveOverlappingCards = (clickedItem) => {
items.forEach((otherItem) => {
if (!isOverlapping(clickedRect, otherItem.getBoundingClientRect())) {
return;
}
const dx = otherCenterX >= clickedCenterX ? 1 : -1;
// Push away from the clicked photo
gsap.timeline()
.to(inner, {
x: dx * gsap.utils.random(18, 34),
rotation: dx * gsap.utils.random(1.4, 2.8),
duration: 0.34,
})
.to(inner, {
x: 0,
y: 0,
rotation: 0,
duration: 0.52,
}); // spring back
});
};
Montagem de imagem do lado do cliente e amostragem de cores
As imagens da galeria são montadas somente após o componente ser renderizado no cliente, evitando carregamento desnecessário de imagens no servidor para uma seção que depende inteiramente de interação e animação. Isso mantém a renderização inicial mais clara e ainda permite que as imagens sejam processadas no navegador para extração de cores.
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
// …
{
mounted && (
<img
src={img.src}
alt={img.alt}
crossOrigin="anonymous"
/>
)
}
A extração de cores dominantes é realizada reduzindo cada imagem para uma tela de 72×72 e agrupando valores de pixels semelhantes em intervalos de cores. Pixels quase pretos, quase brancos e de baixa saturação são removidos do cálculo para que imagens com fundos neutros ainda possam produzir uma cor de destaque significativa. A linha do tempo de rolagem fixada é dividida em dois estágios, com a entrada da foto sendo concluída antes do início da limpeza de listras, seguindo o mesmo padrão de transição progressiva usado nas seções Serviços e Herói.
Notas de encerramento
Olhando para trás, uma arquitetura canvasManager compartilhada desde o início teria simplificado o gerenciamento de múltiplas experiências WebGL em todo o site. Uma das principais lições foi que o nível final de polimento veio menos dos efeitos individuais e mais da sincronização cuidadosa de cada camada da experiência, incluindo rolagem, animações, transições e áudio.
Manter padrões consistentes em todo o projeto, como um ticker GSAP compartilhado, agendamento de tarefas ociosas, animações compatíveis com GPU e áudio da Web sintetizado, ajudou a manter o desempenho da experiência, preservando o nível de detalhe em cada seção. Essa mesma atenção ao tempo e à interação foi levada a cabo também nos momentos menores, incluindo o rodapé, onde a experiência continua além do conteúdo principal.
