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.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -1,40 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
type Transition = (
|
||||
tl: gsap.core.Timeline,
|
||||
prev: HTMLElement,
|
||||
next: HTMLElement,
|
||||
touch: boolean,
|
||||
) => void;
|
||||
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
||||
|
||||
/*
|
||||
* 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").
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
* 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.
|
||||
*
|
||||
* Regras que mantêm tudo estável:
|
||||
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
|
||||
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
|
||||
* - 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).
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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`;
|
||||
/** 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;
|
||||
|
||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
|
||||
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 (
|
||||
@@ -46,125 +47,38 @@ function pageTop(el: HTMLElement) {
|
||||
return top;
|
||||
}
|
||||
|
||||
// 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);
|
||||
/** 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,
|
||||
);
|
||||
}
|
||||
|
||||
// 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, 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(
|
||||
next,
|
||||
{ opacity: 0, clipPath: band(0.36) },
|
||||
{ opacity: 1, clipPath: band(1), duration: 0.65 },
|
||||
0.35,
|
||||
);
|
||||
},
|
||||
// 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,
|
||||
);
|
||||
// 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, touch) => {
|
||||
if (!touch) 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 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, 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, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ opacity: 1, xPercent: 0 },
|
||||
{ opacity: 0.25, xPercent: 20 },
|
||||
0,
|
||||
).fromTo(
|
||||
next,
|
||||
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
|
||||
{ xPercent: 0, rotation: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
},
|
||||
// sobre → contato: o contato cresce do centro, como um zoom de câmera
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 0.92, opacity: 0.2 },
|
||||
0,
|
||||
)
|
||||
.fromTo(
|
||||
next,
|
||||
{
|
||||
scale: touch ? 0.8 : 0.55,
|
||||
clipPath: "inset(0% round 56px)",
|
||||
transformOrigin: () => (touch ? "50% 0%" : `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);
|
||||
},
|
||||
];
|
||||
/** 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;
|
||||
@@ -173,109 +87,103 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const main = mainRef.current;
|
||||
if (!main) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
(async () => {
|
||||
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
|
||||
await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
import("lenis"),
|
||||
]);
|
||||
// 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);
|
||||
|
||||
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);
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 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: "sticky",
|
||||
zIndex: index === 0 ? 30 : index + 1,
|
||||
const following = scenes[index + 1];
|
||||
const trigger = (
|
||||
start: () => number,
|
||||
end: () => number,
|
||||
extra: ScrollTrigger.Vars = {},
|
||||
) => ({
|
||||
trigger: scene,
|
||||
start,
|
||||
end,
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
...extra,
|
||||
});
|
||||
});
|
||||
stick();
|
||||
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: () => 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
|
||||
onLeave: () => gsap.set(next, { clipPath: "none" }),
|
||||
// diagrama de qualidade: desenha uma vez quando a seção começa a entrar
|
||||
onEnter: () => next.classList.add("is-drawing"),
|
||||
},
|
||||
});
|
||||
transition(tl, prev, next, touch);
|
||||
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);
|
||||
|
||||
// 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 = sceneTops();
|
||||
scenes.forEach((scene, index) => {
|
||||
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;
|
||||
});
|
||||
};
|
||||
const queueSync = () => {
|
||||
if (!frame) frame = requestAnimationFrame(syncVisibility);
|
||||
};
|
||||
const onRefresh = () => {
|
||||
stick();
|
||||
queueSync();
|
||||
};
|
||||
syncVisibility();
|
||||
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
|
||||
// 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);
|
||||
@@ -283,7 +191,25 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
});
|
||||
resizeObserver.observe(main);
|
||||
|
||||
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
|
||||
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 ||
|
||||
@@ -318,55 +244,37 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
} 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 (posição relativa à cena, que não muda se ela estiver presa)
|
||||
const index = scenes.findIndex((candidate) =>
|
||||
candidate.contains(target),
|
||||
// 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 = index < 0 ? 0 : sceneTops()[index];
|
||||
const sceneTop = scene ? pageTop(scene) : 0;
|
||||
const top =
|
||||
index <= 0
|
||||
? 0
|
||||
: Math.max(
|
||||
sceneTop,
|
||||
scenes[index] === target
|
||||
? 0
|
||||
: sceneTop +
|
||||
pageTop(target) -
|
||||
pageTop(scenes[index]) -
|
||||
offset,
|
||||
);
|
||||
scene === destination
|
||||
? sceneTop
|
||||
: Math.max(sceneTop, pageTop(destination) - 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 });
|
||||
},
|
||||
});
|
||||
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);
|
||||
ScrollTrigger.removeEventListener("refresh", onRefresh);
|
||||
window.removeEventListener("scroll", queueSync);
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("wheel", wake);
|
||||
window.removeEventListener("scroll", wake);
|
||||
clearTimeout(refreshTimer);
|
||||
resizeObserver.disconnect();
|
||||
context.revert();
|
||||
for (const scene of scenes) {
|
||||
scene.style.visibility = "";
|
||||
scene.style.top = "";
|
||||
}
|
||||
gsap.ticker.remove(tick);
|
||||
lenis.destroy();
|
||||
sleep();
|
||||
lenis?.destroy();
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Reference in new issue
Block a user