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]>
This commit is contained in:
1 parent
df1c1888da
commit
a3a206fb7a
6 files changed
+202
-45
No files matched your search
@@ -38,7 +38,7 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
|
||||
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
|
||||
scrolled
|
||||
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
|
||||
: "border-transparent bg-[var(--ink)]"
|
||||
|
||||
@@ -30,6 +30,8 @@ export default function HeroField() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU para o notebook
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
||||
@@ -42,6 +44,7 @@ export default function HeroField() {
|
||||
const p = Math.min(1, Math.max(0, -rect.top / span));
|
||||
progressRef.current = p;
|
||||
section.style.setProperty("--hero-p", p.toFixed(4));
|
||||
setDone(p >= 0.999);
|
||||
};
|
||||
const queue = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
@@ -92,7 +95,7 @@ export default function HeroField() {
|
||||
{mount && !failed && (
|
||||
<Scene
|
||||
progressRef={progressRef}
|
||||
visible={visible}
|
||||
visible={visible && !done}
|
||||
onReady={handleReady}
|
||||
onError={handleError}
|
||||
/>
|
||||
|
||||
@@ -10,20 +10,25 @@ type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement)
|
||||
* 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 da seção na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
|
||||
/** 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;
|
||||
}
|
||||
// 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`;
|
||||
|
||||
// a seção que sai fica parada na tela enquanto a próxima entra
|
||||
const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
|
||||
@@ -31,28 +36,29 @@ const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
|
||||
// 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 e o notebook assenta vindo de perto
|
||||
// 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);
|
||||
// o hero some antes de o notebook aparecer: sem texto sobre texto (no celular o hero não esmaece antes)
|
||||
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo(
|
||||
next.querySelector(".notebook-story-sticky") ?? next,
|
||||
{ scale: 1.22, opacity: 0 },
|
||||
{ scale: 1, opacity: 1, duration: 0.65 },
|
||||
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 recua para o fundo
|
||||
// notebook → projetos: a seção sobe como um cartão e o notebook recolhe ao fundo
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 0.86, opacity: 0.15 },
|
||||
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,
|
||||
@@ -65,23 +71,23 @@ const TRANSITIONS: Transition[] = [
|
||||
(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 at 50% ${vh() / 2}px)` },
|
||||
{ clipPath: () => `circle(0px ${center()})` },
|
||||
{
|
||||
clipPath: () =>
|
||||
`circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px at 50% ${vh() / 2}px)`,
|
||||
duration: 0.96,
|
||||
clipPath: () => `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
|
||||
ease: "power1.in",
|
||||
},
|
||||
0,
|
||||
)
|
||||
.set(next, { clipPath: "none" }, 1);
|
||||
);
|
||||
},
|
||||
// modalidades → IA: corte lateral, uma tela empurra a outra
|
||||
(tl, prev, next) => {
|
||||
@@ -90,13 +96,15 @@ const TRANSITIONS: Transition[] = [
|
||||
tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo(
|
||||
next,
|
||||
{ xPercent: 100 },
|
||||
{ xPercent: 0 },
|
||||
{ xPercent: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
},
|
||||
// IA → qualidade: a próxima tomba para a frente, em perspectiva
|
||||
// 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) },
|
||||
@@ -104,26 +112,38 @@ const TRANSITIONS: Transition[] = [
|
||||
0,
|
||||
).fromTo(
|
||||
next,
|
||||
{ rotationX: 28, scale: 0.88, transformPerspective: 1600, transformOrigin: "50% 0%" },
|
||||
{ rotationX: 0, scale: 1 },
|
||||
{ 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)`,
|
||||
duration: 0.96,
|
||||
},
|
||||
0,
|
||||
)
|
||||
.set(next, { clipPath: "none" }, 1);
|
||||
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) => {
|
||||
@@ -134,19 +154,23 @@ const TRANSITIONS: Transition[] = [
|
||||
{ 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)" },
|
||||
0,
|
||||
).fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 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;
|
||||
@@ -188,14 +212,85 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -2315,3 +2315,48 @@ html.lenis {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): a página atual
|
||||
recua e desfoca, a nova chega de baixo. O cabeçalho tem nome próprio e fica parado durante a troca. */
|
||||
.site-header {
|
||||
view-transition-name: site-header;
|
||||
}
|
||||
|
||||
::view-transition-group(site-header) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-group(page) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-old(page) {
|
||||
animation: page-out 280ms cubic-bezier(0.7, 0, 0.84, 0) both;
|
||||
}
|
||||
|
||||
::view-transition-new(page) {
|
||||
animation: page-in 640ms var(--ease-out) 160ms both;
|
||||
}
|
||||
|
||||
@keyframes page-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
filter: blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px) scale(1.015);
|
||||
filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(page),
|
||||
::view-transition-new(page) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ViewTransition } from "react";
|
||||
|
||||
/** O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css). */
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
return <ViewTransition name="page">{children}</ViewTransition>;
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
// O App Router usa a versão canary do React, que exporta <ViewTransition>; os tipos estáveis (@types/react 19.1)
|
||||
// só trazem o nome provisório unstable_ViewTransition. Remover quando os tipos acompanharem.
|
||||
import type { ExoticComponent, ViewTransitionProps } from "react";
|
||||
import type {} from "react/experimental";
|
||||
|
||||
declare module "react" {
|
||||
export const ViewTransition: ExoticComponent<ViewTransitionProps>;
|
||||
}
|
||||
Reference in new issue
Block a user