Files
portifolio-evandrodevbr/app/components/cinematic/useCinematicScroll.ts
T
Evandro Fonseca JuniorandClaude Opus 5.5 a3a206fb7a fix(home): transicoes estaveis, diagrama desenhado na entrada, rolagem suave nos links e transicao entre paginas
- visibilidade de cada cena calculada pela posicao da rolagem: nada vaza antes
  da passagem nem aparece atras de transparencias depois
- sem scale em ancestrais do canvas do notebook (tela desalinhada)
- IA -> qualidade em 2D (abertura em portas); a rotacao 3D adiava a pintura
  do diagrama, que agora se desenha junto com a entrada
- iris com aceleracao gradual; hero para de desenhar ao fim do mergulho
- links internos rolam suavemente ate o fim da transicao do destino
- navegacao entre paginas com ViewTransition (app/template.tsx)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 00:50:57 -03:00

305 lines
12 KiB
TypeScript

"use client";
import { type RefObject, useEffect } from "react";
type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) => 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.
*
* Regras que mantêm tudo estável:
* - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1);
* - nenhum ancestral de um <canvas> 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.
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
*/
const vh = () => window.innerHeight;
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
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) {
let top = 0;
for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null)
top += node.offsetTop;
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"
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
const stageOf = (section: HTMLElement) =>
section.querySelector<HTMLElement>(".notebook-story-sticky") ?? section;
// uma linguagem diferente por passagem, na ordem das seções da home
const TRANSITIONS: Transition[] = [
// hero → notebook: o mergulho termina no escuro, o hero some e o notebook abre como tela de cinema
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo(
stageOf(next),
{ opacity: 0, clipPath: "inset(32% 0% 32% 0%)" },
{ opacity: 1, clipPath: "inset(0% 0% 0% 0%)", duration: 0.65 },
0.35,
);
},
// notebook → projetos: a seção sobe como um cartão e o notebook recolhe ao fundo
(tl, prev, next) => {
holdPrev(tl, prev);
tl.fromTo(
stageOf(prev),
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" },
{ opacity: 0.15, clipPath: "inset(8% 6% 8% 6% round 32px)" },
0,
).fromTo(
next,
{ clipPath: "inset(0% 5% 0% 5% round 40px 40px 0px 0px)" },
{ clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
0,
);
},
// 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`;
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,
);
},
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo(
next,
{ xPercent: 100 },
{ xPercent: 0, ease: "power2.out" },
0,
);
},
// IA → qualidade: a tela se abre do centro como duas portas, e o diagrama se desenha.
// Só 2D: com rotação 3D o Chrome adiava a pintura do diagrama (camada grande demais).
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 0.9, opacity: 0.25 },
0,
).fromTo(
next,
{ clipPath: () => `inset(0px 50% ${next.offsetHeight - vh()}px 50%)` },
{ clipPath: () => `inset(0px 0% ${next.offsetHeight - vh()}px 0%)`, ease: "power2.inOut" },
0,
);
const lines = next.querySelectorAll(
".quality-dimensions path, .quality-browser-frame, .quality-blueprint > path.quality-wire, .quality-blueprint > rect.quality-wire, .quality-blueprint > g.quality-wire > *, .quality-browser-button",
);
const labels = next.querySelectorAll(
".quality-blueprint text, .quality-browser-dots, .quality-visual-label, .quality-visual figcaption",
);
if (lines.length) {
tl.set(lines, { attr: { pathLength: 1 }, strokeDasharray: 1 }, 0).fromTo(
lines,
{ strokeDashoffset: 1 },
{ strokeDashoffset: 0, duration: 0.4, stagger: { amount: 0.25 } },
0.3,
);
}
if (labels.length) {
tl.fromTo(labels, { opacity: 0 }, { opacity: 1, duration: 0.25, stagger: { amount: 0.2 } }, 0.5);
}
},
// qualidade → sobre: cortina diagonal
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 8 }, 0).fromTo(
next,
{ clipPath: () => `polygon(0% 0px, 0% 0px, -30% ${vh()}px, -30% ${vh()}px)` },
{ clipPath: () => `polygon(0% 0px, 130% 0px, 100% ${vh()}px, -30% ${vh()}px)` },
0,
);
},
// sobre → contato: o contato cresce do centro, como um zoom de câmera
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 0.92, opacity: 0.2 },
0,
)
.fromTo(
next,
{
scale: 0.55,
clipPath: "inset(0% round 56px)",
transformOrigin: () => `50% ${vh() / 2}px`,
},
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
0,
)
.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0);
},
];
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 (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cancelled = false;
let cleanup = () => {};
(async () => {
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
import("lenis"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
const scenes = Array.from(main.children) as HTMLElement[];
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima do notebook
gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 });
});
TRANSITIONS.forEach((transition, index) => {
const prev = scenes[index];
const next = scenes[index + 1];
if (!prev || !next) return;
const tl = gsap.timeline({
defaults: { ease: "none", duration: 1 },
scrollTrigger: {
trigger: next,
start: () => pageTop(next) - vh(),
end: () => pageTop(next),
scrub: true,
invalidateOnRefresh: true,
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
onLeave: () => gsap.set(next, { clipPath: "none" }),
},
});
transition(tl, prev, next);
});
}, 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.
let tops: number[] = [];
const syncVisibility = () => {
const y = window.scrollY;
scenes.forEach((scene, index) => {
const from = index === 0 ? -Infinity : tops[index] - vh();
const to = index === scenes.length - 1 ? Infinity : tops[index + 1];
const visibility = y >= from && y <= to ? "" : "hidden";
if (scene.style.visibility !== visibility) scene.style.visibility = visibility;
});
};
const measure = () => {
tops = scenes.map(pageTop);
syncVisibility();
};
measure();
ScrollTrigger.addEventListener("refresh", measure);
window.addEventListener("scroll", syncVisibility, { passive: true });
// botões e links internos: em vez do salto, a página "avança o vídeo" 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();
// 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));
const offset =
Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0;
const top =
!scene || scene === scenes[0]
? 0
: Math.max(pageTop(scene), scene === target ? 0 : pageTop(target) - offset);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
lenis.scrollTo(top, {
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
easing: easeInOutCubic,
// o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco
onComplete: () => {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
},
});
};
document.addEventListener("click", onClick, true);
cleanup = () => {
document.removeEventListener("click", onClick, true);
ScrollTrigger.removeEventListener("refresh", measure);
window.removeEventListener("scroll", syncVisibility);
context.revert();
for (const scene of scenes) scene.style.visibility = "";
gsap.ticker.remove(tick);
lenis.destroy();
};
})();
return () => {
cancelled = true;
cleanup();
};
}, [mainRef]);
}