Tres camadas de pontos ligadas por colunas de dados, animadas no vertex shader; o hero fica fixo em telas grandes enquanto a camera atravessa as camadas. Secoes sobem como cartoes via animation-timeline: view() (sem JS). Co-Authored-By: Claude Opus 5.5 <[email protected]>
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
|
|
|
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
|
ssr: false,
|
|
});
|
|
|
|
function supportsWebGL2() {
|
|
if (typeof window.WebGL2RenderingContext === "undefined") return false;
|
|
try {
|
|
const context = document.createElement("canvas").getContext("webgl2");
|
|
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
|
return context !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
|
* e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
|
|
*/
|
|
export default function HeroField() {
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const progressRef = useRef(0);
|
|
const [mount, setMount] = useState(false);
|
|
const [visible, setVisible] = useState(false);
|
|
const [ready, setReady] = useState(false);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
|
if (!section) return;
|
|
let frame = 0;
|
|
const update = () => {
|
|
frame = 0;
|
|
const rect = section.getBoundingClientRect();
|
|
const span = Math.max(rect.height - window.innerHeight, window.innerHeight * 0.8);
|
|
const p = Math.min(1, Math.max(0, -rect.top / span));
|
|
progressRef.current = p;
|
|
section.style.setProperty("--hero-p", p.toFixed(4));
|
|
};
|
|
const queue = () => {
|
|
if (!frame) frame = requestAnimationFrame(update);
|
|
};
|
|
update();
|
|
window.addEventListener("scroll", queue, { passive: true });
|
|
window.addEventListener("resize", queue);
|
|
|
|
let intersecting = false;
|
|
const sync = () => setVisible(intersecting && !document.hidden);
|
|
const observer = new IntersectionObserver(([entry]) => {
|
|
intersecting = entry.isIntersecting;
|
|
sync();
|
|
});
|
|
observer.observe(section);
|
|
document.addEventListener("visibilitychange", sync);
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
window.removeEventListener("scroll", queue);
|
|
window.removeEventListener("resize", queue);
|
|
observer.disconnect();
|
|
document.removeEventListener("visibilitychange", sync);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
if (!supportsWebGL2()) return;
|
|
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
|
|
if (typeof window.requestIdleCallback === "function") {
|
|
const id = window.requestIdleCallback(() => setMount(true), { timeout: 2500 });
|
|
return () => window.cancelIdleCallback(id);
|
|
}
|
|
const id = setTimeout(() => setMount(true), 1200);
|
|
return () => clearTimeout(id);
|
|
}, []);
|
|
|
|
const handleReady = useCallback(() => setReady(true), []);
|
|
const handleError = useCallback(() => setFailed(true), []);
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
aria-hidden="true"
|
|
className="hero-field"
|
|
data-ready={ready && !failed ? "" : undefined}
|
|
>
|
|
{mount && !failed && (
|
|
<Scene
|
|
progressRef={progressRef}
|
|
visible={visible}
|
|
onReady={handleReady}
|
|
onError={handleError}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|