Files
portifolio-evandrodevbr/app/components/cinematic/useCinematicScroll.ts
T
Evandro Fonseca Junior c9a0b80656 perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
2026-10-14 03:32:23 -03:00

287 lines
9.8 KiB
TypeScript

"use client";
import { type RefObject, useEffect } from "react";
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
/*
* Rolagem como um vídeo: o GSAP liga cada passagem de uma cena para a seguinte à posição da rolagem (rolar
* para cima volta o "vídeo") e, no mouse, o Lenis suaviza a roda.
*
* Uma única passagem para a home inteira, só com opacidade e um deslocamento vertical curto:
* - a cena que entra chega com fade e sobe ENTER_OFFSET px até o lugar dela;
* - a cena que sai some com fade e termina SEMPRE em opacidade 0 (nenhuma cena anterior fica "fantasma" atrás
* das seguintes);
* - nada de recorte (clip-path), escala ou rotação, e nenhuma cena é escondida com visibility: o conteúdo
* nasce visível no HTML do servidor e a opacidade só muda por rolagem (Ctrl+F e leitores de tela enxergam
* tudo, e sem JavaScript tudo aparece).
* As cenas ficam no fluxo normal da página, uma depois da outra. A única rolagem "presa" é a do CSS: o hero
* (sticky) e a vitrine de projetos (sticky); este arquivo não prende nada.
*
* Cada cena tem uma entrada e uma saída, cada uma com o próprio gatilho, e a saída só começa depois que a
* entrada da mesma cena terminou (cenas curtas não brigam pela mesma opacidade). Qualquer filho direto de
* <main> é uma cena; nada aqui depende de uma seção específica existir.
*/
/** Deslocamento vertical, em px, com que a cena chega (entre 24 e 40). */
export const ENTER_OFFSET = 32;
/** A passagem corre enquanto o topo da cena que entra sobe de 1 altura de tela até esta fração dela. */
export const PASSAGE_END = 0.2;
/** Âncoras mais distantes que isto (em alturas de tela) são um salto direto. */
export const JUMP_SCREENS = 3;
/** Nenhuma âncora anima por mais que isto. */
export const MAX_ANCHOR_SECONDS = 0.8;
/** Depois deste tempo parada, a rolagem suave solta o relógio do GSAP. */
export const IDLE_SECONDS = 1;
const vh = () => window.innerHeight;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das passagens, getBoundingClientRect não). */
function pageTop(el: HTMLElement) {
let top = 0;
for (
let node: HTMLElement | null = el;
node;
node = node.offsetParent as HTMLElement | null
)
top += node.offsetTop;
return top;
}
/** A cena chega: fade e subida curta. Ao terminar, o estilo inline some (sem transform sobrando na cena). */
export function enterScene(tl: gsap.core.Timeline, scene: HTMLElement) {
tl.fromTo(
scene,
{ opacity: 0, y: ENTER_OFFSET },
{
opacity: 1,
y: 0,
ease: "power2.out",
clearProps: "opacity,transform",
immediateRender: false,
},
0,
);
}
/** A cena sai: fade até 0. É o último estado dela até a rolagem voltar. */
export function exitScene(tl: gsap.core.Timeline, scene: HTMLElement) {
tl.fromTo(
scene,
{ opacity: 1 },
{ opacity: 0, ease: "power2.in", immediateRender: false },
0,
);
}
/** Duração (s) da rolagem até uma âncora a `distance` px; `null` = salto direto. */
export function anchorDuration(distance: number, viewport: number) {
const screens = Math.abs(distance) / Math.max(1, viewport);
if (screens > JUMP_SCREENS) return null;
return Math.min(MAX_ANCHOR_SECONDS, 0.3 + screens * 0.2);
}
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const main = mainRef.current;
if (!main) return;
if (prefersReducedMotion()) return;
let cancelled = false;
let cleanup = () => {};
(async () => {
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
const smooth = !isCoarsePointer();
const [{ gsap }, { ScrollTrigger }, lenisModule] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
smooth ? import("lenis") : Promise.resolve(null),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
// Lenis: o relógio do GSAP só roda enquanto a rolagem anima; parada por IDLE_SECONDS, ele é solto
const lenis = lenisModule
? new lenisModule.default({ lerp: 0.075, wheelMultiplier: 0.85 })
: null;
let awake = false;
let lastBusy = 0;
const tick = (time: number) => {
lenis?.raf(time * 1000);
if (lenis?.isScrolling) lastBusy = time;
else if (time - lastBusy > IDLE_SECONDS) sleep();
};
function sleep() {
if (!awake) return;
awake = false;
gsap.ticker.remove(tick);
}
const wake = () => {
if (!lenis) return;
lastBusy = gsap.ticker.time;
if (awake) return;
awake = true;
gsap.ticker.add(tick);
};
if (lenis) {
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.lagSmoothing(0);
wake();
window.addEventListener("wheel", wake, { passive: true });
window.addEventListener("scroll", wake, { passive: true });
}
const scenes = Array.from(main.children) as HTMLElement[];
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
const following = scenes[index + 1];
const trigger = (
start: () => number,
end: () => number,
extra: ScrollTrigger.Vars = {},
) => ({
trigger: scene,
start,
end,
scrub: true,
invalidateOnRefresh: true,
...extra,
});
const timeline = (vars: ScrollTrigger.Vars) =>
gsap.timeline({
defaults: { ease: "none", duration: 1 },
scrollTrigger: vars,
});
if (index > 0)
enterScene(
timeline(
trigger(
() => pageTop(scene) - vh(),
() => pageTop(scene) - vh() * PASSAGE_END,
),
),
scene,
);
if (following)
exitScene(
timeline(
trigger(
// só depois de a própria entrada terminar, mesmo numa cena mais curta que a tela
() =>
Math.max(
pageTop(scene) - vh() * PASSAGE_END,
pageTop(following) - vh(),
),
() => pageTop(following) - vh() * PASSAGE_END,
),
),
scene,
);
});
}, main);
// a página mudou de altura (fontes, imagens, seções que abrem e fecham): o GSAP remede as passagens
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
});
resizeObserver.observe(main);
const scrollToTop = (top: number) => {
const distance = top - window.scrollY;
const duration = anchorDuration(distance, vh());
if (lenis) {
wake();
lenis.scrollTo(top, {
immediate: duration === null,
duration: duration ?? 0,
easing: easeInOutCubic,
});
} else {
window.scrollTo({
top,
behavior: duration === null ? "instant" : "smooth",
});
}
};
// botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) até o destino
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (
url.origin !== location.origin ||
url.pathname !== location.pathname ||
!url.hash
)
return;
let target: HTMLElement | null = null;
try {
target = document.getElementById(
decodeURIComponent(url.hash.slice(1)),
);
} catch {}
if (!target) return;
event.preventDefault();
// o alvo é a cena inteira: para no topo dela; alvo interno: desconta o cabeçalho fixo, sem passar do topo da cena
const destination = target;
const scene = scenes.find((candidate) =>
candidate.contains(destination),
);
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const sceneTop = scene ? pageTop(scene) : 0;
const top =
scene === destination
? sceneTop
: Math.max(sceneTop, pageTop(destination) - offset);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
scrollToTop(top);
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
if (!destination.hasAttribute("tabindex")) destination.tabIndex = -1;
destination.focus({ preventScroll: true });
};
document.addEventListener("click", onClick, true);
cleanup = () => {
document.removeEventListener("click", onClick, true);
window.removeEventListener("wheel", wake);
window.removeEventListener("scroll", wake);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
sleep();
lenis?.destroy();
};
})();
return () => {
cancelled = true;
cleanup();
};
}, [mainRef]);
}