Conteúdo deste artigo
- Tech Stack
- Duas dicas de rolagem que salvaram este projeto
- Dica 1: basta desativar o Lenis no celular
- Dica 2: use uma div de rolagem personalizada em vez do corpo (para iOS)
- As seções de texto em escala
- A pilha de cartas (slides adesivas)
- O acordeão que gruda no fundo
- A revelação do feed (cartas emergindo do vazio)
- O Mega Menu
- Inferno do navegador no aplicativo
- Reflexões
- Site ativo
- Créditos
Horeca começou como uma landing page simples, mas rapidamente evoluiu para algo muito mais ambicioso. Texto que aumenta para preencher a janela de visualização e permanece girado em um caractere específico. Cabeçalhos sanfonados que ficam na parte inferior da janela de visualização em vez de na parte superior. Uma seção de feed onde os cartões saem da profundidade z em direção à câmera. Uma pilha de cartas, um mega menu suspenso e tudo o que é necessário para funcionar no celular sem quebrar. Na época, atingir esse nível de design de movimento exigia uma combinação de Webflow e GSAP personalizado, que eventualmente cresceu para cerca de 2.000 linhas de código de animação.
A parte que a maioria dos estudos de caso ignora é a parte de edição. É fácil construir um site com muita animação se você não se importa com o que acontece quando o cliente abre o Designer para alterar um título. Empilhe alguns divs posicionados de forma absoluta, enterre os elementos com 10 camadas de profundidade, codifique os valores de pixel, pronto. Parece ótimo no estudo de caso, mas é um pesadelo na produção. Então, o Webflow nos deu o CMS e o construtor, o Lumos nos deu o sistema de classes e as unidades fluidas para que a capacidade de resposta permanecesse alinhada enquanto construímos, e o GSAP estendeu o que era possível além dos recursos de interação do Webflow na época.
Neste estudo de caso, quero examinar as partes que demoraram mais e quebraram mais. A matemática do pivô do texto em escala, o acordeão pegajoso, a animação de profundidade do feed, o Lenis no desastre móvel e o inferno do navegador no aplicativo. Hoje, muitas dessas interações podem ser criadas diretamente no Webflow usando cronogramas visuais do GSAP, reduzindo significativamente a quantidade de código personalizado necessário.
Tech Stack
- Webflow como CMS e construtor visual
- Estrutura Lumos para a classe sistema, unidades de fluido e estrutura de componentes
- GSAP como o núcleo de animação
- ScrollTrigger para tudo vinculado à rolagem
- SplitText para divisões de linhas, palavras e caracteres
- Lenis para rolagem suave apenas na área de trabalho (mais sobre por que “apenas na área de trabalho” mais tarde)
- Variáveis CSS personalizadas para todos os temas, espaçamentos e matemática do acordeão
Nenhuma etapa de construção. Sem empacotador. Tudo é enviado como código personalizado embutido nas configurações e incorporações do projeto do Webflow. Essa restrição molda muitas das decisões abaixo.
Duas dicas de rolagem que salvaram este projeto
Dica 1: basta desativar o Lenis no celular
Lenis é ótimo no desktop. No celular, foi um desastre: rolagem com falhas, seções pegajosas e nervosas, elementos fixados dessincronizados de seus gatilhos.
Passei muito tempo tentando consertar isso. Configurações ajustadas, casos extremos perseguidos, complicaram demais toda a camada de detecção. Nada disso funcionou. A solução real foi chata: não execute o Lenis no celular.
window.isMobile = function () {
if (navigator.userAgentData && navigator.userAgentData.mobile !== undefined) {
return navigator.userAgentData.mobile;
}
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
};
if (!isMobile()) {
lenis = new Lenis({ /* ...config */ });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
}
Lição: se a biblioteca está lutando contra a plataforma, pare de corrigir e desligue-a onde não pertence.
Dica 2: use uma div de rolagem personalizada em vez do corpo (para iOS)
No iOS, a barra de endereço que diminui e expande na rolagem redimensiona a janela de visualização. Cada vez que isso acontece, o ScrollTrigger recalcula e seus cronogramas cuidadosamente cronometrados são redefinidos ou desviados. Você vê isso na maioria dos sites com muitas animações, a rolagem parece errada e as animações são reativadas do nada.
A correção é retirar a rolagem do corpo e colocá-la em um contêiner de altura fixa. O corpo obtém overflow: hidden e um .page_wrap div se torna a região de rolagem real. Sem interação na barra de endereço = sem instabilidade na janela de visualização = gatilhos estáveis.
const MOBILE_SCROLLER = ".page_wrap";
function getScrollContainer() {
return isMobile()
? document.querySelector(MOBILE_SCROLLER) || window
: window;
}
ScrollTrigger.defaults({ scroller: getScrollContainer() });
Advertências se você fizer isso:
- Agora você mesmo está gerenciando a rolagem. Qualquer coisa que seja esperada
windowpois o scroller precisa ser apontado para o seu contêiner. - Links âncora (
href="#section") param de funcionar imediatamente. Você precisa interceptar esses cliques e executar sua própria lógica de rolagem no contêiner personalizado. - Qualquer coisa que leia
window.scrollYprecisa ler oscrollTopdo seu contêiner.
É mais fiação, mas a recompensa são cronogramas sólidos do ScrollTrigger no iOS.
As seções de texto em escala
O design exigia um bloco de texto onde uma parte da linha é dimensionada enormemente enquanto o texto ao redor se move verticalmente e a palavra de escala permanece girada em um caractere específico. Dois exemplos disso na página.
A abordagem ingênua é dimensionar todo o elemento de texto. Isso funciona visualmente por meio segundo e então a página inteira reflui porque um transform: scale(50) em um elemento de texto arrasta a altura do documento para a estratosfera se o pai não estiver restrito. O desempenho diminui, o ScrollTrigger recalcula e a página treme.
A abordagem real: dimensionar apenas por meio de transform, fixar a seção com CSS fixo, controlar o progresso por meio de um único ScrollTrigger e pré-medir o deslocamento do pivô para que possamos traduzir o elemento dimensionado de volta para manter o caractere pivô no centro da janela de visualização.
function initializeLongScrollAnimation(longScrollSection, index) {
const stickyContent = longScrollSection.querySelector(
"[data-gsap-state='pinned']"
);
const textTop = longScrollSection.querySelector("[data-gsap-text='top']");
const textMiddle = longScrollSection.querySelector(
"[data-gsap-text='middle']"
);
const textBottom = longScrollSection.querySelector(
"[data-gsap-text='bottom']"
);
const pivotElement = textMiddle?.querySelector(
"[data-gsap-pivot='pivot']"
);
let pivotOffsetX = 0;
if (textMiddle && pivotElement) {
const textMiddleRect = textMiddle.getBoundingClientRect();
const pivotRect = pivotElement.getBoundingClientRect();
const textMiddleCenterX =
textMiddleRect.left + textMiddleRect.width / 2;
const pivotCenterX =
pivotRect.left + pivotRect.width / 2 + pivotRect.width * 0.1;
pivotOffsetX = pivotCenterX - textMiddleCenterX;
gsap.set(textMiddle, {
scale: 0,
transformOrigin: "50% 50%",
});
}
ScrollTrigger.create({
trigger: longScrollSection,
start: "top top",
end: "bottom bottom",
scrub: !isMobile() ? true : 1,
onUpdate: (self) => {
const progress = self.progress;
const progress1 = Math.min(progress / 0.6, 1);
const progress2 = !isMobile()
? progress >= 0.3
? (progress - 0.3) / 0.55
: 0
: progress >= 0.45
? (progress - 0.45) / 0.2
: 0;
if (textTop) {
gsap.set(textTop, { y: `${progress1 * -100}%` });
}
if (textBottom) {
gsap.set(textBottom, { y: `${progress1 * 100}%` });
}
if (textMiddle && pivotElement) {
const currentScale = !isMobile()
? Math.max(0, progress1 * 2.25)
: Math.max(0, progress1 * 2.95);
const scaledPivotOffset = pivotOffsetX * currentScale;
const targetTranslateX = -scaledPivotOffset;
const middleOpacity = Math.min(
Math.max((progress1 - 0) / 0.33, 0),
1
);
gsap.set(textMiddle, {
scale: currentScale,
x: targetTranslateX,
transformOrigin: "50% 50%",
opacity: middleOpacity,
});
}
},
});
}
Três coisas importam nesse código:
- Um ScrollTrigger, múltiplas derivações de progresso. Em vez de empilhar três ou quatro ScrollTriggers com valores de início/fim sobrepostos, temos um único gatilho que orienta toda a seção e calcula
progress1,progress2eprogress3como intervalos derivados. Isso é muito mais barato do que deixar o ScrollTrigger calcular três posições separadas em cada quadro. - Deslocamento do pivô pré-medido. Medimos onde o caractere pivô fica em relação ao centro do elemento de escala exatamente uma vez, na configuração. Então, em cada atualização de rolagem, multiplicamos esse deslocamento pela escala atual e traduzimos o elemento pelo negativo, o que mantém o caractere pivô fixado no centro da janela de visualização à medida que o restante do texto cresce em torno dele.
-
gsap.setem vez degsap.todentroonUpdate. Isso não é óbvio, mas é importante. Dentro de um ScrollTrigger limpo, você dispara todos os eventos de rolagem.gsap.tocria uma instância de interpolação em cada quadro;gsap.setgrava diretamente no elemento. Para animações baseadas em rolagem, a diferença aumenta rapidamente.
A variante móvel tem maior escalabilidade (2,95x vs 2,25x) porque a janela de visualização é mais estreita e a palavra de escala precisa parecer igualmente dominante.
A pilha de cartas (slides adesivas)
O design tinha uma pilha de cartas grandes que eram fixadas uma após a outra na parte superior da janela de visualização, com a carta ativa sendo dimensionada e girando levemente conforme a próxima chegava.
O instinto aqui é usar GSAP pin para cada cartão. Nós tentamos isso. Funcionou, mas cada pino adiciona um espaçador de pinos ScrollTrigger ao DOM, a matemática do layout fica complicada no redimensionamento e, no celular, a fixação seria dessincronizada quando a barra de endereço entrasse em colapso.
Substituímos a fixação GSAP por CSS position: sticky em cada .slide-wrapper e, em seguida, usamos GSAP apenas para as animações de rotação, escala e fade:
const cardsWrappers = gsap.utils.toArray(".slide-wrapper").slice(0, -1);
const cards = gsap.utils.toArray(".card_stack_component");
cardsWrappers.forEach((wrapper, i) => {
const card = cards[i];
gsap.to(card, {
rotationZ: (Math.random() - 0.5) * 10,
scale: 0.7,
rotationX: 40,
ease: "none",
scrollTrigger: {
trigger: wrapper,
start: "top top",
end: "bottom center",
endTrigger: ".g_component_layout",
scrub: !isMobile() ? true : 1,
},
});
gsap.to(card, {
autoAlpha: 0,
ease: "power1.in",
scrollTrigger: {
trigger: card,
start: "top -80%",
end: "+=" + 0.2 * window.innerHeight,
scrub: !isMobile() ? true : 1,
},
});
});
O CSS sticky faz todo o trabalho de fixação. O GSAP apenas cuida da transformação visual. O desempenho aumentou dramaticamente quando nos afastamos de pin: true aqui.
O acordeão que gruda no fundo
Este foi o quebra-cabeça de layout mais complicado do projeto e tem a solução mais simples.
O design pedia um acordeão onde cada cabeçalho, conforme você rola, se encaixa na parte inferior da janela de visualização em vez de na parte superior. Assim, à medida que você rola para baixo, os itens do acordeão são empilhados de baixo para cima. A maioria das implementações fixas fica no topo porque é isso que position: sticky; top: 0 faz. Não há comportamento bottom: 0 equivalente que funcione bem dentro de um pai de rolagem, especialmente quando os itens são empilhados verticalmente.
O truque era posicionar os cabeçalhos absolutamente após a primeira renderização e usar propriedades personalizadas CSS para calcular onde cada um deveria chegar:
const accordionContainer = document.querySelector('[data-gsap="inview"]');
const accordionHeaders = document.querySelectorAll(".accordion_header");
const accordionWrapper = document.querySelector(
'[data-gsap="accordion-wrapper"]'
);
let headerHeight = "8rem";
if (accordionContainer && accordionHeaders.length > 0 && accordionWrapper) {
const totalItemsCount = accordionHeaders.length;
const sectionHeight = accordionContainer.getBoundingClientRect().height;
const wrapperHeight = accordionWrapper.offsetHeight;
const headerHeightPx = !isMobile()
? `${wrapperHeight / totalItemsCount}px`
: headerHeight;
const sectionHeightPx = `${sectionHeight}px`;
document.documentElement.style.setProperty(
"--total-items",
totalItemsCount
);
document.documentElement.style.setProperty(
"--section-height",
sectionHeightPx
);
document.documentElement.style.setProperty(
"--header-height",
headerHeightPx
);
accordionHeaders.forEach((header, index) => {
const itemPosition = index + 1;
header.style.setProperty("--item-position", itemPosition);
if (!isMobile()) {
setTimeout(() => {
header.style.position = "absolute";
}, 1000);
}
});
}
O JS não faz quase nada. Ele grava quatro variáveis CSS em :root e uma variável por cabeçalho (--item-position). A aderência real acontece na folha de estilo usando cálculos contra essas variáveis. No desktop, os cabeçalhos vão para position: absolute assim que a matemática for escrita, e o CSS lida com o empilhamento inferior em camadas a partir daí.
Todo o efeito é executado em um único ScrollTrigger que apenas alterna um .inview class:
ScrollTrigger.create({
trigger: accordionWrapper,
start: `top bottom-=${wrapperHeight}`,
onEnter: () => {
accordionContainers.forEach((container) => {
container.classList.add("inview");
});
if (!isMobile()) {
refreshScrollTriggers();
}
},
onLeaveBack: () => {
accordionContainers.forEach((container) => {
container.classList.remove("inview");
});
},
});
A lição: toda vez que recorremos à animação JavaScript neste projeto, perguntamos primeiro se o CSS poderia fazer isso mais barato. Na maioria das vezes, poderia.
A revelação do feed (cartas emergindo do vazio)
Esta seção fazia parte da construção original, mas foi removida posteriormente a pedido do cliente. Mantendo o detalhamento aqui porque foi um dos problemas de desempenho mais complicados do projeto.
A seção de rodapé tinha uma pilha de itens de feed que eram revelados conforme você rolava, vindo da profundidade z em direção à câmera. O item mais próximo de z=1 tornou-se o “ativo” e acionou o aparecimento gradual de uma imagem de fundo correspondente.
Esta foi a animação mais sensível ao desempenho na página porque ela foi executada em cada quadro de rolagem e tocou todos os cartões simultaneamente. As versões anteriores usavam gsap.to dentro do loop de atualização, que criava centenas de micro-interpolações por segundo. A correção foi o uso disciplinado de gsap.set e o armazenamento em cache de cada elemento consultado.
class FeedItemsAnimation {
constructor(container) {
if (this.feedItems.length === 0) {
console.warn("No feed items found - skipping feed animation");
return;
}
// Cache feed images once instead of querying every frame
item.querySelector(".feed_img")
);
this.scrollerHeight = this.isMobile
? currentScroller && currentScroller !== window
? currentScroller.clientHeight
: window.innerHeight
: window.innerHeight;
this.zDepthConfig = {
desktop: { initialSpacing: -1800, totalRange: 3000, maxOffset: 1800 * this.numItems },
mobile: { initialSpacing: -900, totalRange: 1500, maxOffset: 900 * this.numItems },
};
this.currentConfig = this.isMobile
? this.zDepthConfig.mobile
: this.zDepthConfig.desktop;
this.init();
}
getProgress = () => {
this.resetClosestItem();
this.feedItems.forEach((item, index) => {
const z = gsap.getProperty(item, "z");
const normalizedZ = gsap.utils.normalize(
-this.currentConfig.totalRange,
0,
z
);
item.dataset.z = normalizedZ;
// gsap.set instead of gsap.to - no tween creation per frame
gsap.set(item, { opacity: normalizedZ + 0.2 });
const itemImage = this.feedImages[index];
if (itemImage) {
const scaleMultiplier = this.isMobile ? 0.6 : 0.5;
const baseScale = this.isMobile ? 0.8 : 0.75;
gsap.set(itemImage, {
scale: normalizedZ * scaleMultiplier + baseScale,
});
}
const zDifference = Math.abs(normalizedZ - this.targetZValue);
if (zDifference < this.closestZDifference) {
}
});
const newIndex = this.feedItems.indexOf(this.closestItem);
if (newIndex !== this.currIndex) {
this.handleBackgroundTransition(newIndex);
}
};
}
Quatro otimizações melhoraram esse desempenho:
- Cache
.feed_imgreferências na construção. A versão anterior consultava a imagem de cada cartão em cada quadro. -
gsap.setpara atualizações por quadro,gsap.toapenas para o desbotamento único do fundo. A troca de plano de fundo acontece uma vez por alteração do “cartão ativo”, não em todos os quadros, entãotoestá bem aí. - Esfregue 0,3, não 0,1. Valores de limpeza mais baixos parecem responsivos, mas são acionados com mais frequência e queimam a CPU. 0,3 parece idêntico à vista e custa visivelmente menos.
- CSS
position: stickynovamente em vez de GSAPpin. O mesmo raciocínio da pilha de cartas.
Dispositivos móveis usam espaçamento z mais restrito (-900 vs -1800) porque a janela de visualização menor significa que os cartões preenchem visualmente a tela mais rápido, então eles não precisam de tanta profundidade para parecer que estão “emergindo do vazio”.
O Mega Menu
A navegação possui um mega menu expandido ao passar o mouse. O botão de acionamento se transforma em um painel de vários links: o texto do acionador aparece caractere por caractere, o acionador é recolhido e as palavras do link aparecem de baixo para cima. Inverta ao sair do mouse.
O desafio não foi a animação em si. Foi uma gestão estatal. As linhas do tempo orientadas por foco enfrentam problemas rapidamente se você não eliminar a linha do tempo anterior antes de iniciar uma nova. Sequências rápidas de mouseenter/mouseleave/mouseenter causam interpolações sobrepostas e os personagens acabam presos no meio da animação.
let hoverInTl = null;
let hoverOutTl = null;
let isOpen = false;
function openMenu() {
if (isOpen) return;
isOpen = true;
if (hoverInTl) hoverInTl.kill();
if (hoverOutTl) hoverOutTl.kill();
hoverInTl = gsap.timeline({
onComplete: () => { hoverInTl = null; },
});
hoverInTl
.to(triggerSplit.chars, {
yPercent: 100,
opacity: 0,
duration: 0.35,
ease: "Quart.easeIn",
stagger: 0.05,
}, 0)
.to(triggerIcon, {
x: 100,
opacity: 0,
duration: 0.35,
ease: "Quart.easeIn",
}, 0);
hoverInTl
.set(navMenuTrigger, { position: "absolute" }, 0)
.set(navMenuMask, { position: "relative", display: "flex" }, 0);
hoverInTl.to(navMenuMask, {
width: "auto",
opacity: 1,
pointerEvents: "auto",
duration: 0.5,
ease: "Quart.easeInOut",
}, 0.15);
linkSplits.forEach((splitData, index) => {
hoverInTl.to(splitData.split.words, {
yPercent: 0,
opacity: 1,
duration: 0.35,
ease: "Back.easeOut",
stagger: -0.03,
}, 0.25 + index * 0.05);
});
}
O padrão: um sinalizador isOpen evita chamadas redundantes, os cronogramas de abertura e fechamento se matam na entrada e a linha do tempo mantém sua própria referência até a conclusão, ponto em que ela se anula. Esta é a maneira mais limpa de lidar com máquinas de estado acionadas por foco no GSAP sem vazar cronogramas.
O escalonamento negativo em linkSplits (stagger: -0.03) também vale a pena notar: GSAP suporta valores de escalonamento negativos que executam animações em ordem inversa em toda a matriz, o que proporciona um fluxo de leitura mais agradável quando as palavras aparecem de um lado específico.
Inferno do navegador no aplicativo
Vale a pena sinalizar porque nos custou dois dias perto do final da construção.
Quando alguém abre um site Webflow dentro do navegador do aplicativo LinkedIn ou Instagram, você não está no Safari ou Chrome. Você está em um WebView simplificado que está atrasado em relação às especificações CSS mais recentes por meses ou anos. Propriedades como aspect-ratio, certos valores clip-path e alguns backdrop-filter comportamentos falham silenciosamente. O sistema de animação sobreviveu principalmente, mas os layouts quebraram.
A correção foi tediosa em vez de inteligente: detectar recursos, fornecer substitutos de CSS para todas as propriedades que os navegadores do aplicativo não suportavam e aceitar que o site pareceria 95% polido nesses ambientes, em vez de 100%. A alternativa era detectar navegadores no aplicativo e exibir um prompt “toque para abrir no navegador”, que consideramos, mas rejeitamos porque acrescenta atrito.
Se fizéssemos esse projeto novamente, construiríamos a camada substituta do navegador no aplicativo primeiro, e não por último.
Reflexões
Algumas coisas que eu mudaria em uma segunda passagem.
Construa a arquitetura móvel primeiro, não por último. A decisão Lenis-vs-native-scroll chegou tarde e a modernização afetou quase todas as animações da página. Se tivéssemos projetado o sistema de rolagem em torno da restrição móvel desde o primeiro dia, a camada da área de trabalho teria sido um aprimoramento simples, em vez de um padrão que precisava ser removido.
Aprenda ainda mais com CSS. Cada vez que substituímos um pin GSAP por position: sticky, a página ficou mais rápida e o código ficou menor. Poderíamos ter feito essa troca mais cedo e de forma mais agressiva. A seção acordeão é o exemplo mais claro: quase todo o seu comportamento reside em duas variáveis CSS e uma alternância de classe.
As interações do Webflow melhoraram desde então. Quando começamos, entregar esse nível de design de movimento dependia muito de código personalizado junto com o Webflow. Hoje, você pode fazer cerca de 75% do que enviamos aqui diretamente dentro do Webflow Designer usando os novos cronogramas visuais do GSAP, sem arquivo JS, sem incorporação, apenas quadros-chave na tela. O feed de profundidade e escala, o acordeão fixo na parte inferior e o texto de escala com bloqueio de pivô ainda precisariam de código real, mas a maior parte do resto não. A maneira certa de pensar sobre o Webflow agora é: construir tudo o que puder nas linhas do tempo visuais e, em seguida, colocar GSAP personalizado em camadas apenas onde ele realmente não consegue alcançar. Não busque o código por reflexo.
O desempenho envolve principalmente não animar as coisas. As maiores vitórias neste projeto vieram da remoção de animações: substituição de pinos por fixos, substituição de #to por set, cache de DOM referências e aceitar que o esfregaço 0.3 parece igual ao esfregaço 0.1. Nada sobre adicionar novas bibliotecas de animação ou novos truques de otimização. Apenas fazendo menos trabalho por quadro.
O Webflow pode transportar sites de produção com esse nível de design de movimento. Requer apenas sair deliberadamente do construtor visual da camada de animação e tratar o JS personalizado como um software real, em vez de um trecho de script que você cola na parte inferior da página.
Site ativo
Visite o site ao vivo .
Créditos
- Estúdio: upgreight
- Desenvolvimento e animação de fluxo web: Kamran Imtiaz
- Desenvolvimento adicional de fluxo da Web: Lukas Schorn
- Design: Anna Prozhyzhko
- Líder de projeto e garantia de qualidade: Julian Ortler
- Showreel de estudo de caso: Tobias Graves Morris
