From b2f3899dc242558b3d969015073df2916d026806 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Mon, 5 Oct 2026 03:08:42 -0300 Subject: [PATCH] fix(mobile): sem tremido nas transicoes com rolagem rapida no celular A secao que sai fica presa com position: sticky (o compositor do navegador a mantem em sincronia com a rolagem) em vez de ser deslocada pelo JavaScript. No toque, a secao que entra sobe com o dedo: a trava de posicao pelo GSAP chegava um quadro atrasada na rolagem nativa e tremia. Cada passagem mantem um efeito proprio sem disputar a posicao. Folga de visibilidade cobre o quadro de atraso. Posicoes das cenas somam alturas (sticky altera offsetTop). Co-Authored-By: Claude Opus 5.5 --- .../cinematic/useCinematicScroll.ts | 199 +++++++++++------- 1 file changed, 125 insertions(+), 74 deletions(-) diff --git a/app/components/cinematic/useCinematicScroll.ts b/app/components/cinematic/useCinematicScroll.ts index b401e27..cecc319 100644 --- a/app/components/cinematic/useCinematicScroll.ts +++ b/app/components/cinematic/useCinematicScroll.ts @@ -6,19 +6,26 @@ type Transition = ( tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement, + touch: boolean, ) => void; /* * Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição * da rolagem (rolar para cima volta o "vídeo"). - * Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada - * para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada - * "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais. + * + * Quem segura as seções paradas durante uma passagem: + * - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim + * dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem; + * - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis + * rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento + * chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o + * dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala). * * Regras que mantêm tudo estável: - * - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1); + * - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1); * - nenhum ancestral de um recebe scale (o R3F mede o canvas com getBoundingClientRect); - * - a seção que saiu fica invisível depois da passagem, para não aparecer atrás de transparências. + * - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não + * aparecer atrás de transparências e para o navegador não pintar o que está coberto. * Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh). */ @@ -28,7 +35,7 @@ const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`; /** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */ -export function pageTop(el: HTMLElement) { +function pageTop(el: HTMLElement) { let top = 0; for ( let node: HTMLElement | null = el; @@ -39,60 +46,66 @@ export function pageTop(el: HTMLElement) { return top; } -// a seção que sai fica parada na tela enquanto a próxima entra -const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) => - tl.fromTo(prev, { y: 0 }, { y: vh }, 0); -// a seção que entra também fica parada no topo: a transição acontece "no lugar" +// a seção que entra fica parada no topo (só desktop, ver o comentário acima) const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) => tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0); // uma linguagem diferente por passagem, na ordem das seções da home const TRANSITIONS: Transition[] = [ // hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema - (tl, prev, next) => { - holdPrev(tl, prev); + (tl, prev, next, touch) => { + tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0); + if (touch) { + tl.fromTo( + next, + { + opacity: 0.3, + clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)", + }, + { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" }, + 0, + ); + return; + } holdNext(tl, next); const band = (open: number) => () => `inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`; tl.fromTo( - prev, - { autoAlpha: 1 }, - { autoAlpha: 0, duration: 0.4 }, - 0, - ).fromTo( next, { opacity: 0, clipPath: band(0.36) }, { opacity: 1, clipPath: band(1), duration: 0.65 }, 0.35, ); }, - // projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera - (tl, prev, next) => { - holdPrev(tl, prev); - holdNext(tl, next); - const center = () => `at 50% ${vh() / 2}px`; + // projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera + (tl, prev, next, touch) => { tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 1.18, opacity: 0.2 }, 0, - ) - // power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem - .fromTo( - next, - { clipPath: () => `circle(0px ${center()})` }, - { - clipPath: () => - `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`, - ease: "power1.in", - }, - 0, - ); + ); + // no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo + const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`); + const radius = () => + touch + ? Math.hypot(window.innerWidth / 2, vh()) + : Math.hypot(window.innerWidth / 2, vh() / 2); + if (!touch) holdNext(tl, next); + // power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem + tl.fromTo( + next, + { clipPath: () => `circle(0px ${center()})` }, + { + clipPath: () => `circle(${radius()}px ${center()})`, + ease: "power1.in", + }, + 0, + ); }, // modalidades → IA: corte lateral, uma tela empurra a outra - (tl, prev, next) => { - holdPrev(tl, prev); - holdNext(tl, next); + (tl, prev, next, touch) => { + if (!touch) holdNext(tl, next); tl.fromTo( prev, { xPercent: 0, opacity: 1 }, @@ -100,23 +113,24 @@ const TRANSITIONS: Transition[] = [ 0, ).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0); }, - // IA → qualidade: a seção sobe mais rápido que a rolagem, como um cartão, sobre a anterior que escurece. + // IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece. // Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o // desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha // uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter). - (tl, prev, next) => { - holdPrev(tl, prev); - tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo( - next, - { yPercent: 0, y: () => vh() * 0.35 }, - { y: 0, ease: "power2.out" }, - 0, - ); + (tl, prev, next, touch) => { + tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0); + // no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo + if (!touch) + tl.fromTo( + next, + { y: () => vh() * 0.35 }, + { y: 0, ease: "power2.out" }, + 0, + ); }, // qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform) - (tl, prev, next) => { - holdPrev(tl, prev); - holdNext(tl, next); + (tl, prev, next, touch) => { + if (!touch) holdNext(tl, next); tl.fromTo( prev, { opacity: 1, xPercent: 0 }, @@ -130,9 +144,8 @@ const TRANSITIONS: Transition[] = [ ); }, // sobre → contato: o contato cresce do centro, como um zoom de câmera - (tl, prev, next) => { - holdPrev(tl, prev); - holdNext(tl, next); + (tl, prev, next, touch) => { + if (!touch) holdNext(tl, next); tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, @@ -142,9 +155,9 @@ const TRANSITIONS: Transition[] = [ .fromTo( next, { - scale: 0.55, + scale: touch ? 0.8 : 0.55, clipPath: "inset(0% round 56px)", - transformOrigin: () => `50% ${vh() / 2}px`, + transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`), }, { scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" }, 0, @@ -180,15 +193,35 @@ export function useCinematicScroll(mainRef: RefObject) { gsap.ticker.add(tick); gsap.ticker.lagSmoothing(0); + // toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript + const touch = window.matchMedia( + "(hover: none) and (pointer: coarse)", + ).matches; const scenes = Array.from(main.children) as HTMLElement[]; + // Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o + // offsetTop de uma cena presa passa a incluir o deslocamento dela. + const sceneTops = () => { + let top = pageTop(main); + return scenes.map((scene) => { + const current = top; + top += scene.offsetHeight; + return current; + }); + }; + const stick = () => { + for (const scene of scenes) + scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`; + }; + const context = gsap.context(() => { scenes.forEach((scene, index) => { // quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte gsap.set(scene, { - position: "relative", + position: "sticky", zIndex: index === 0 ? 30 : index + 1, }); }); + stick(); TRANSITIONS.forEach((transition, index) => { const prev = scenes[index]; const next = scenes[index + 1]; @@ -197,8 +230,8 @@ export function useCinematicScroll(mainRef: RefObject) { defaults: { ease: "none", duration: 1 }, scrollTrigger: { trigger: next, - start: () => pageTop(next) - vh(), - end: () => pageTop(next), + start: () => sceneTops()[index + 1] - vh(), + end: () => sceneTops()[index + 1], scrub: true, invalidateOnRefresh: true, // passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente @@ -207,23 +240,26 @@ export function useCinematicScroll(mainRef: RefObject) { onEnter: () => next.classList.add("is-drawing"), }, }); - transition(tl, prev, next); + transition(tl, prev, next, touch); }); }, main); - // Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na - // tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior; - // depois não aparece atrás de transparências, e o navegador não pinta o que está coberto. - // As posições são lidas a cada quadro (offsetTop é barato com o layout já calculado): guardá-las - // deixava a visibilidade errada quando fontes, imagens ou o bloco "Experiência" mudavam a altura. + // Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a + // passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto + // de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no + // desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa). let frame = 0; const syncVisibility = () => { frame = 0; const y = window.scrollY; - const tops = scenes.map(pageTop); + const tops = sceneTops(); scenes.forEach((scene, index) => { - const from = index === 0 ? -Infinity : tops[index] - vh(); - const to = index === scenes.length - 1 ? Infinity : tops[index + 1]; + const from = + index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1); + const to = + index === scenes.length - 1 + ? Infinity + : tops[index + 1] + vh() * 0.25; const visibility = y >= from && y <= to ? "" : "hidden"; if (scene.style.visibility !== visibility) scene.style.visibility = visibility; @@ -232,8 +268,12 @@ export function useCinematicScroll(mainRef: RefObject) { const queueSync = () => { if (!frame) frame = requestAnimationFrame(syncVisibility); }; + const onRefresh = () => { + stick(); + queueSync(); + }; syncVisibility(); - ScrollTrigger.addEventListener("refresh", queueSync); + ScrollTrigger.addEventListener("refresh", onRefresh); window.addEventListener("scroll", queueSync, { passive: true }); // a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens let refreshTimer: ReturnType | undefined; @@ -279,18 +319,26 @@ export function useCinematicScroll(mainRef: RefObject) { if (!target) return; event.preventDefault(); // nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo; - // alvos internos descontam o cabeçalho fixo - const scene = scenes.find((candidate) => candidate.contains(target)); + // alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa) + const index = scenes.findIndex((candidate) => + candidate.contains(target), + ); const offset = Number.parseFloat( getComputedStyle(document.documentElement).scrollPaddingTop, ) || 0; + const sceneTop = index < 0 ? 0 : sceneTops()[index]; const top = - !scene || scene === scenes[0] + index <= 0 ? 0 : Math.max( - pageTop(scene), - scene === target ? 0 : pageTop(target) - offset, + sceneTop, + scenes[index] === target + ? 0 + : sceneTop + + pageTop(target) - + pageTop(scenes[index]) - + offset, ); if (location.hash !== url.hash) history.pushState(null, "", url.hash); lenis.scrollTo(top, { @@ -307,13 +355,16 @@ export function useCinematicScroll(mainRef: RefObject) { cleanup = () => { document.removeEventListener("click", onClick, true); - ScrollTrigger.removeEventListener("refresh", queueSync); + ScrollTrigger.removeEventListener("refresh", onRefresh); window.removeEventListener("scroll", queueSync); cancelAnimationFrame(frame); clearTimeout(refreshTimer); resizeObserver.disconnect(); context.revert(); - for (const scene of scenes) scene.style.visibility = ""; + for (const scene of scenes) { + scene.style.visibility = ""; + scene.style.top = ""; + } gsap.ticker.remove(tick); lenis.destroy(); };