Add logo loading screen gated on animation readiness

- Full-screen loader whose only content is the logo animating: the "B"
  draws in and fills, the base facets trace in, then a wave runs along
  them until the page is ready (pure CSS, plays before JS)
- Loader gate holds the loader until GSAP + fonts and every 3D scene's
  first frame are ready, then waits 1s (min 1.4s total) before leaving;
  hard 8s limit, head-script fallback if JS never boots
- Scroll reveals, SplitText and scene entry animations start only once
  the loader leaves, so above-the-fold entries play in view
- Internal links fade the loader back in before navigating, so page
  changes are one seamless dark transition
- No loader for no-JS or reduced-motion visitors

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-05 07:17:54 +01:00
co-authored by Claude Opus 5.5
parent 6e7219d30b
commit c3514d2607
9 changed files with 432 additions and 21 deletions
+19 -12
View File
@@ -1,6 +1,7 @@
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { hideLoader, onLoaderDone, showLoader } from "../loader/gate";
/**
* # Site-wide motion system
@@ -19,6 +20,8 @@ import { SplitText } from "gsap/SplitText";
*
* Elements are pre-hidden by CSS only once `html.motion` is set by the inline
* head script, so no-JS / reduced-motion visitors always see content.
* Nothing is animated until the page loader is dismissed (../loader/gate.ts),
* so above-the-fold entries play in view instead of behind the overlay.
*/
type Variant =
@@ -193,8 +196,9 @@ function scan(root: ParentNode) {
});
}
/** Fade the page out before following internal links. */
/** Cover the page with the loader before following internal links. */
function setupPageExit() {
let leaving = false;
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
@@ -206,17 +210,15 @@ function setupPageExit() {
if (/\.(pdf|png|jpe?g|webp|gif|svg|zip)$/i.test(url.pathname)) return;
e.preventDefault();
gsap.to("main", {
autoAlpha: 0,
y: -12,
duration: 0.28,
ease: "power2.in",
onComplete: () => location.assign(url.href),
});
if (leaving) return;
leaving = true;
showLoader().then(() => location.assign(url.href));
};
// restore if the page comes back from the back/forward cache
const onShow = (e: PageTransitionEvent) => {
if (e.persisted) gsap.set("main", { clearProps: "all" });
if (!e.persisted) return;
leaving = false;
hideLoader();
};
document.addEventListener("click", onClick);
window.addEventListener("pageshow", onShow);
@@ -241,8 +243,15 @@ export function initMotion() {
registered = true;
}
scan(document);
setupPageExit();
cleanups.push(onLoaderDone(start));
return destroyMotion;
}
/** Runs once the loader leaves: animate what's on the page, watch for more. */
function start() {
scan(document);
// Pick up content React mounts later (tabs, lists, client fetches).
observer = new MutationObserver((mutations) => {
@@ -264,8 +273,6 @@ export function initMotion() {
window.addEventListener("load", onLoad);
document.fonts?.ready.then(onLoad).catch(() => {});
cleanups.push(() => window.removeEventListener("load", onLoad));
return destroyMotion;
}
function destroyMotion() {