perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -4,38 +4,30 @@ 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.
|
||||
* Rolagem da home. Duas coisas, nenhuma delas com biblioteca de animação:
|
||||
*
|
||||
* 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.
|
||||
* 1. As passagens entre cenas são CSS (globals.css, `animation-timeline: view()`): o conteúdo de cada seção
|
||||
* (`.ds-section-shell`) chega com fade e uma subida curta enquanto a seção entra na janela, e o fundo da
|
||||
* seção fica sempre inteiro (seções do mesmo tom não piscam em cinza). Nada de JavaScript por quadro, e onde
|
||||
* o navegador não tem o recurso o conteúdo simplesmente já está lá. Qualquer filho direto de <main> é uma
|
||||
* cena; nada aqui depende de uma seção específica existir.
|
||||
* 2. Este hook cuida só do que o CSS não faz: no mouse, o Lenis suaviza a roda (com o relógio ligado apenas
|
||||
* enquanto algo se move, nunca com a página parada), e os links internos (#id) "avançam o vídeo" até o
|
||||
* destino em vez de saltar. No toque a rolagem nativa corre em outra thread: nem o Lenis é baixado.
|
||||
*
|
||||
* 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.
|
||||
* As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky).
|
||||
*/
|
||||
|
||||
/** 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. */
|
||||
/** Depois deste tempo parada, a rolagem suave solta o relógio (requestAnimationFrame). */
|
||||
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). */
|
||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms que as passagens aplicam). */
|
||||
function pageTop(el: HTMLElement) {
|
||||
let top = 0;
|
||||
for (
|
||||
@@ -47,32 +39,6 @@ function pageTop(el: HTMLElement) {
|
||||
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);
|
||||
@@ -83,204 +49,158 @@ export function anchorDuration(distance: number, viewport: number) {
|
||||
const easeInOutCubic = (t: number) =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
|
||||
/** Clique do botão principal, sem tecla modificadora, que nenhum outro código já tratou. */
|
||||
export function isPlainPrimaryClick(event: MouseEvent) {
|
||||
return !(
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
);
|
||||
}
|
||||
|
||||
/** O link (<a>) que o clique atingiu, se abre na mesma aba; `null` para qualquer outro clique. */
|
||||
function sameTabLink(event: MouseEvent) {
|
||||
const link =
|
||||
event.target instanceof Element ? event.target.closest("a[href]") : null;
|
||||
if (!(link instanceof HTMLAnchorElement)) return null;
|
||||
return !link.target || link.target === "_self" ? link : null;
|
||||
}
|
||||
|
||||
/** O `#id` de um link para esta mesma página; `null` se ele leva a outra página ou não tem âncora. */
|
||||
function hashOnThisPage(link: HTMLAnchorElement) {
|
||||
const url = new URL(link.href);
|
||||
const same =
|
||||
url.origin === location.origin && url.pathname === location.pathname;
|
||||
return same && url.hash ? url.hash : null;
|
||||
}
|
||||
|
||||
function elementForHash(hash: string) {
|
||||
try {
|
||||
return document.getElementById(decodeURIComponent(hash.slice(1)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Link para outro ponto da MESMA página (`#id`): o elemento de destino e o hash; `null` para qualquer outro clique. */
|
||||
export function samePageHashTarget(event: MouseEvent) {
|
||||
const link = sameTabLink(event);
|
||||
const hash = link && hashOnThisPage(link);
|
||||
const target = hash ? elementForHash(hash) : null;
|
||||
return hash && target ? { target, hash } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posição da rolagem (px) para mostrar `target`: o alvo que é a cena inteira para no topo dela; um alvo
|
||||
* interno desconta o cabeçalho fixo, sem passar do topo da cena.
|
||||
*/
|
||||
export function anchorTop(target: HTMLElement, scenes: readonly HTMLElement[]) {
|
||||
const scene = scenes.find((candidate) => candidate.contains(target));
|
||||
const sceneTop = scene ? pageTop(scene) : 0;
|
||||
if (scene === target) return sceneTop;
|
||||
const offset =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
return Math.max(sceneTop, pageTop(target) - offset);
|
||||
}
|
||||
|
||||
type SmoothScroll = {
|
||||
scrollTo: (top: number, duration: number | null) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lenis com o relógio ligado só enquanto a rolagem se move; parado por IDLE_SECONDS, o requestAnimationFrame
|
||||
* para. Devolve `null` (sem criar nada) se a página já saiu de cena enquanto o pacote carregava.
|
||||
*/
|
||||
async function startSmoothScroll(
|
||||
isAlive: () => boolean,
|
||||
): Promise<SmoothScroll | null> {
|
||||
const { default: Lenis } = await import("lenis");
|
||||
if (!isAlive()) return null;
|
||||
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
|
||||
let frame = 0;
|
||||
let lastBusy = 0;
|
||||
const tick = (time: number) => {
|
||||
lenis.raf(time);
|
||||
if (lenis.isScrolling) lastBusy = time;
|
||||
frame =
|
||||
time - lastBusy > IDLE_SECONDS * 1000 ? 0 : requestAnimationFrame(tick);
|
||||
};
|
||||
const wake = () => {
|
||||
lastBusy = performance.now();
|
||||
if (!frame) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
window.addEventListener("wheel", wake, { passive: true });
|
||||
window.addEventListener("scroll", wake, { passive: true });
|
||||
wake();
|
||||
return {
|
||||
scrollTo(top, duration) {
|
||||
wake();
|
||||
lenis.scrollTo(top, {
|
||||
immediate: duration === null,
|
||||
duration: duration ?? 0,
|
||||
easing: easeInOutCubic,
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener("wheel", wake);
|
||||
window.removeEventListener("scroll", wake);
|
||||
cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
lenis.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const main = mainRef.current;
|
||||
if (!main) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
let smooth: SmoothScroll | null = null;
|
||||
|
||||
(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);
|
||||
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
|
||||
if (!isCoarsePointer())
|
||||
startSmoothScroll(() => !cancelled).then((scroll) => {
|
||||
smooth = scroll;
|
||||
});
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
const scrollToTop = (top: number) => {
|
||||
const duration = anchorDuration(top - window.scrollY, vh());
|
||||
if (smooth) smooth.scrollTo(top, duration);
|
||||
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();
|
||||
};
|
||||
})();
|
||||
// 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 (!isPlainPrimaryClick(event)) return;
|
||||
const hit = samePageHashTarget(event);
|
||||
if (!hit) return;
|
||||
event.preventDefault();
|
||||
if (location.hash !== hit.hash) history.pushState(null, "", hit.hash);
|
||||
scrollToTop(
|
||||
anchorTop(hit.target, Array.from(main.children) as HTMLElement[]),
|
||||
);
|
||||
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
|
||||
if (!hit.target.hasAttribute("tabindex")) hit.target.tabIndex = -1;
|
||||
hit.target.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("click", onClick, true);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
document.removeEventListener("click", onClick, true);
|
||||
smooth?.destroy();
|
||||
};
|
||||
}, [mainRef]);
|
||||
}
|
||||
Reference in new issue
Block a user