diff --git a/src/components/loader/PageLoader.tsx b/src/components/loader/PageLoader.tsx
new file mode 100644
index 0000000..df724b3
--- /dev/null
+++ b/src/components/loader/PageLoader.tsx
@@ -0,0 +1,52 @@
+/**
+ * Full-screen loader shown until the page's entry animations are ready
+ * (see ./gate.ts). It's just the logo, animating: the "B" draws itself in
+ * and fills, the five base facets trace in after it, then a wave runs along
+ * the facets until the page is ready. Pure CSS, so it plays before any JS.
+ *
+ * Plain server-rendered markup driven only by classes on , so React
+ * never touches it after hydration:
+ *
+ * - visible only under `html.motion` (set by the head script), so no-JS and
+ * reduced-motion visitors never see it
+ * - `html.loader-done` fading out / hidden
+ * - `html.loader-gone` fully hidden; animations paused
+ * - `html.loader-leaving` fading back in for a page exit
+ */
+
+/** Base facets, left → right along the zigzag (wave order). */
+const FACETS = [
+ "M151.94,875.51L151.94,1042.32L0,958.98L0,786.14L151.94,875.51Z",
+ "M306.83,789.02L306.83,957.3L151.94,1042.32L151.94,875.51L156.15,873.19L306.83,789.02Z",
+ "M461.71,874.03L461.71,1042.32L306.83,957.3L306.83,789.02L461.71,874.03Z",
+ "M610.63,784.67L610.63,952.95L461.71,1042.32L461.71,874.03L464.52,872.35L610.63,784.67Z",
+ "M610.63,619.33L610.63,784.67L461.71,702.53L468.56,698.7L563.9,645.44L610.63,619.33Z",
+];
+
+const LETTER =
+ "M610.63,515.37L610.63,619.37L590.72,607.63L461.72,531.55L306.83,617L151.94,702.39L138.33,709.89L0,786.14L0,0L362,0C434.173,0 490.98,15.707 532.42,47.12C534.38,48.613 536.297,50.137 538.17,51.69C575.177,82.423 593.98,125.773 594.58,181.74L594.58,184.49C594.58,225.95 583.717,260.877 561.99,289.27C540.263,317.663 511.353,336.547 475.26,345.92C518.027,355.92 551.283,376.477 575.03,407.59C598.777,438.703 610.643,474.63 610.63,515.37ZM368.49,519.89C378.163,512.21 383,499.673 383,482.28C383,448.193 363.28,431.15 323.84,431.15L222.6,431.15L222.6,531.42L323.87,531.42C343.923,531.42 358.797,527.577 368.49,519.89ZM368,227.61C368,210.23 363.153,197.36 353.46,189C343.767,180.64 328.893,176.463 308.84,176.47L222.6,176.47L222.6,276.74L308.83,276.74C328.877,276.74 343.75,272.74 353.45,264.74C363.15,256.74 368,244.363 368,227.61Z";
+
+export default function PageLoader() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/loader/gate.ts b/src/components/loader/gate.ts
new file mode 100644
index 0000000..905d290
--- /dev/null
+++ b/src/components/loader/gate.ts
@@ -0,0 +1,175 @@
+/**
+ * # Loader gate
+ *
+ * Keeps the page loader (see ./PageLoader.tsx) up until everything that
+ * animates on load is ready, then dismisses it and opens the gate so the
+ * entry animations start together, in view.
+ *
+ * - `holdLoader(label)` registers something the loader must wait for and
+ * returns an idempotent `release()`. (Motion system + fonts, each 3D scene.)
+ * - `onLoaderDone(cb)` runs `cb` once the loader starts leaving (immediately
+ * if it already has). Entry animations hook in here.
+ * - `showLoader()` brings the overlay back to cover the page before an
+ * internal navigation, so the next page's loader takes over seamlessly.
+ *
+ * Once everything is ready the loader lingers for `READY_HOLD` (and at
+ * least until the logo has drawn itself in) before it leaves.
+ *
+ * State lives on `window` so separately bundled chunks share one gate.
+ * The loader is dismissed anyway after `MAX_WAIT` so a slow or failed
+ * asset never traps the visitor.
+ */
+
+const DONE_CLASS = "loader-done";
+const GONE_CLASS = "loader-gone";
+const LEAVING_CLASS = "loader-leaving";
+
+/** Pause after the page is ready, before the loader leaves. */
+const READY_HOLD = 1000;
+/** Never leave before the logo has finished drawing itself in. */
+const MIN_SHOW = 1400;
+/** Hard limit: leave regardless of anything still pending. */
+const MAX_WAIT = 8000;
+/** Overlay fade-out (matches the CSS); its animations pause after. */
+const FADE_OUT = 700;
+
+type GateState = {
+ pending: Set;
+ /** Dismissal has started (no new holds accepted). */
+ done: boolean;
+ /** Overlay is leaving; entry animations may run. */
+ open: boolean;
+ listeners: (() => void)[];
+ settleRaf: number;
+ settleTimer: number;
+};
+
+declare global {
+ interface Window {
+ __LOADER?: GateState;
+ }
+}
+
+const root = () => document.documentElement;
+
+function gate(): GateState {
+ if (window.__LOADER) return window.__LOADER;
+ // The head script may already have dropped the loader (JS was slow).
+ const already = root().classList.contains(DONE_CLASS);
+ const state: GateState = {
+ pending: new Set(),
+ done: already,
+ open: already,
+ listeners: [],
+ settleRaf: 0,
+ settleTimer: 0,
+ };
+ window.__LOADER = state;
+ if (!state.done) {
+ const remaining = MAX_WAIT - performance.now();
+ window.setTimeout(() => dismiss(true), Math.max(remaining, 0));
+ }
+ return state;
+}
+
+function dismiss(force = false) {
+ const state = gate();
+ if (state.done) return;
+ state.done = true;
+ if (force && state.pending.size) {
+ console.warn("Loader timed out waiting for:", [...state.pending]);
+ }
+ root().classList.remove(LEAVING_CLASS);
+ root().classList.add(DONE_CLASS);
+ state.open = true;
+ window.setTimeout(() => {
+ if (root().classList.contains(DONE_CLASS)) {
+ root().classList.add(GONE_CLASS);
+ }
+ }, FADE_OUT + 50);
+ state.listeners.splice(0).forEach((cb) => {
+ try {
+ cb();
+ } catch (error) {
+ console.error(error);
+ }
+ });
+}
+
+function cancelSettle(state: GateState) {
+ cancelAnimationFrame(state.settleRaf);
+ window.clearTimeout(state.settleTimer);
+}
+
+/**
+ * All holds released: wait a couple of frames for late mounts, then hold
+ * for `READY_HOLD` before leaving. A new hold in the meantime cancels it.
+ */
+function settle(state: GateState) {
+ cancelSettle(state);
+ state.settleRaf = requestAnimationFrame(() => {
+ state.settleRaf = requestAnimationFrame(() => {
+ if (state.pending.size || state.done) return;
+ const wait = Math.max(
+ READY_HOLD,
+ MIN_SHOW - performance.now(),
+ );
+ state.settleTimer = window.setTimeout(() => {
+ if (!state.pending.size) dismiss();
+ }, wait);
+ });
+ });
+}
+
+let holdId = 0;
+
+export function holdLoader(label: string): () => void {
+ const state = gate();
+ if (state.done) return () => {};
+ const key = `${label}#${++holdId}`;
+ state.pending.add(key);
+ cancelSettle(state);
+
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ state.pending.delete(key);
+ if (!state.done && !state.pending.size) settle(state);
+ };
+}
+
+export function onLoaderDone(cb: () => void): () => void {
+ const state = gate();
+ if (state.open) {
+ cb();
+ return () => {};
+ }
+ state.listeners.push(cb);
+ return () => {
+ const i = state.listeners.indexOf(cb);
+ if (i >= 0) state.listeners.splice(i, 1);
+ };
+}
+
+export function isLoaderDone() {
+ return gate().open;
+}
+
+/**
+ * Fade the overlay back in (without the logo) and resolve once the page
+ * is covered. Used for page exits.
+ */
+export function showLoader(duration = 320): Promise {
+ const el = root();
+ el.classList.add(LEAVING_CLASS);
+ el.classList.remove(DONE_CLASS, GONE_CLASS);
+ return new Promise((resolve) => window.setTimeout(resolve, duration));
+}
+
+/** Undo `showLoader()` (page restored from the back/forward cache). */
+export function hideLoader() {
+ const el = root();
+ el.classList.remove(LEAVING_CLASS);
+ el.classList.add(DONE_CLASS, GONE_CLASS);
+}
diff --git a/src/components/motion/head-script.ts b/src/components/motion/head-script.ts
index a51d087..4ba0e33 100644
--- a/src/components/motion/head-script.ts
+++ b/src/components/motion/head-script.ts
@@ -1,11 +1,17 @@
/**
* Inline script: runs before first paint so motion elements can be
- * pre-hidden without a flash. Skipped for reduced-motion users. If the motion
- * system hasn't booted within 3s (JS error, slow network), everything is
- * revealed so content is never stuck invisible.
+ * pre-hidden without a flash and the page loader (src/components/loader) is
+ * up from the first frame. Skipped for reduced-motion users (no loader, no
+ * pre-hiding).
+ *
+ * Fallbacks so content is never stuck invisible: if the motion system hasn't
+ * booted within 3s everything is revealed, and if the loader gate never
+ * initialised (JS error, very slow network) the loader is dropped at 5s.
+ * Once the gate is up it enforces its own time limit.
*/
export const MotionHeadScript = `(function(){try{
if(window.matchMedia("(prefers-reduced-motion: reduce)").matches)return;
var d=document.documentElement;d.classList.add("motion");
setTimeout(function(){if(!window.__MOTION_READY)d.classList.add("motion-fallback");},3000);
+setTimeout(function(){if(!window.__LOADER)d.classList.add("loader-done");},5000);
}catch(e){}})();`;
diff --git a/src/components/motion/motion.ts b/src/components/motion/motion.ts
index 4d9e40a..6ec398f 100644
--- a/src/components/motion/motion.ts
+++ b/src/components/motion/motion.ts
@@ -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() {
diff --git a/src/components/motion/use-motion.tsx b/src/components/motion/use-motion.tsx
index af79d45..32e6134 100644
--- a/src/components/motion/use-motion.tsx
+++ b/src/components/motion/use-motion.tsx
@@ -1,15 +1,30 @@
import React from "react";
+import { holdLoader } from "../loader/gate";
-/** Boot the site-wide GSAP motion system (see ./motion.ts). */
+/**
+ * Boot the site-wide GSAP motion system (see ./motion.ts). Holds the page
+ * loader until GSAP is loaded and fonts are settled, so SplitText measures
+ * final line breaks.
+ */
export default function useMotion() {
React.useEffect(() => {
let destroy: (() => void) | undefined;
let cancelled = false;
- import("./motion").then(({ initMotion }) => {
- if (!cancelled) destroy = initMotion();
- });
+ const release = holdLoader("motion");
+
+ Promise.all([
+ import("./motion"),
+ document.fonts?.ready.catch(() => {}),
+ ])
+ .then(([{ initMotion }]) => {
+ if (!cancelled) destroy = initMotion();
+ })
+ .catch((error) => console.warn("Motion failed to load", error))
+ .finally(release);
+
return () => {
cancelled = true;
+ release();
destroy?.();
};
}, []);
diff --git a/src/components/three/SceneCanvas.tsx b/src/components/three/SceneCanvas.tsx
index 79804fb..c8836ce 100644
--- a/src/components/three/SceneCanvas.tsx
+++ b/src/components/three/SceneCanvas.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { twMerge } from "tailwind-merge";
+import { holdLoader } from "../loader/gate";
/**
* Each scene is its own lazily-loaded chunk; three.js + the stage are a
@@ -49,6 +50,8 @@ export default function SceneCanvas({
let disposed = false;
let dispose: (() => void) | undefined;
+ // The page loader waits for the first frame (or a failure).
+ const release = holdLoader(`scene:${scene}`);
(async () => {
try {
@@ -69,18 +72,24 @@ export default function SceneCanvas({
: l.mobile) ?? {}
);
},
- onReady: () => setReady(true),
+ onReady: () => {
+ setReady(true);
+ release();
+ },
});
if (disposed) handle.dispose();
else dispose = handle.dispose;
} catch (error) {
// No WebGL / failed load: the page simply stays text-only.
console.warn(`SceneCanvas(${scene}) failed`, error);
+ } finally {
+ release();
}
})();
return () => {
disposed = true;
+ release();
dispose?.();
};
}, [scene]);
diff --git a/src/components/three/stage.ts b/src/components/three/stage.ts
index d27e059..faacb69 100644
--- a/src/components/three/stage.ts
+++ b/src/components/three/stage.ts
@@ -1,5 +1,6 @@
import * as THREE from "three";
import { gsap } from "gsap";
+import { onLoaderDone } from "../loader/gate";
/**
* # Three.js stage
@@ -7,7 +8,8 @@ import { gsap } from "gsap";
* Shared renderer / camera / loop for every page graphic. A scene plugs in
* by returning a `SceneInstance`; the stage handles framing, resize, pointer
* smoothing, the GSAP-driven entry animation, pausing when off-screen and
- * reduced motion.
+ * reduced motion. The scene renders behind the page loader while it's up
+ * (warming shaders), but the entry only starts once the loader leaves.
*/
export type Stage = {
@@ -147,6 +149,7 @@ export async function createStage(opts: StageOptions) {
let last = 0;
let elapsed = 0;
let entryTween: gsap.core.Tween | undefined;
+ let gateOpen = false;
function loop(now: number) {
const dt = Math.min((now - last) / 1000, 0.05);
@@ -164,6 +167,11 @@ export async function createStage(opts: StageOptions) {
running = true;
last = performance.now();
raf = requestAnimationFrame(loop);
+ beginEntry();
+ }
+
+ function beginEntry() {
+ if (!gateOpen || !running) return;
entryTween ??= gsap.to(stage, {
entry: 1,
duration: opts.entryDuration ?? 2.8,
@@ -194,10 +202,16 @@ export async function createStage(opts: StageOptions) {
renderFrame(0, 0);
opts.onReady?.();
+ const offLoader = onLoaderDone(() => {
+ gateOpen = true;
+ beginEntry();
+ });
+
return {
stage,
dispose() {
stop();
+ offLoader();
entryTween?.kill();
visibility.disconnect();
resizeObserver.disconnect();
diff --git a/src/layouts/main/index.tsx b/src/layouts/main/index.tsx
index a7f5393..9c0b846 100644
--- a/src/layouts/main/index.tsx
+++ b/src/layouts/main/index.tsx
@@ -5,6 +5,7 @@ import Footer from "./Footer";
import { twMerge } from "tailwind-merge";
import MobileMenu from "./(sections)/MobileMenu";
import useMotion from "@/src/components/motion/use-motion";
+import PageLoader from "@/src/components/loader/PageLoader";
type Props = PropsWithChildren;
@@ -23,6 +24,7 @@ export default function Layout({ children }: Props) {
+
);
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index a9f4f0a..fb7ef54 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -115,6 +115,137 @@ html.motion:not(.motion-fallback) [data-reveal-stagger] > * {
margin-bottom: -0.08em;
}
+/* ---- Page loader (see src/components/loader) ---- */
+.page-loader {
+ display: none;
+}
+
+html.motion .page-loader {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #0a0a0a;
+ opacity: 1;
+ visibility: visible;
+ transition:
+ opacity 0.7s cubic-bezier(0.65, 0, 0.35, 1),
+ visibility 0s linear 0s;
+}
+
+html.motion.loader-done .page-loader {
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition:
+ opacity 0.7s cubic-bezier(0.65, 0, 0.35, 1),
+ visibility 0s linear 0.7s;
+}
+
+html.loader-gone .page-loader * {
+ animation-play-state: paused !important;
+}
+
+html.motion.loader-leaving .page-loader {
+ transition-duration: 0.32s, 0s;
+}
+
+/* -- the logo -- */
+.page-loader-logo {
+ --d0: 0.1s; /* start offset for the whole sequence */
+ width: 56px;
+ height: auto;
+ overflow: visible;
+ color: #fff;
+ transition:
+ opacity 0.45s ease,
+ transform 0.7s cubic-bezier(0.65, 0, 0.35, 1);
+}
+
+html.loader-done .page-loader-logo {
+ opacity: 0;
+ transform: scale(1.06);
+}
+
+/* Page exits never show it. */
+html.loader-leaving .page-loader-logo {
+ opacity: 0;
+}
+
+.pl-shape {
+ fill: currentColor;
+ fill-opacity: 0;
+ stroke: currentColor;
+ stroke-width: 9;
+ stroke-linejoin: round;
+ stroke-dasharray: 1 1;
+ stroke-dashoffset: 1;
+}
+
+/* 1. the "B" draws itself in, then fills */
+.pl-letter {
+ animation:
+ pl-draw 1s cubic-bezier(0.65, 0, 0.35, 1) var(--d0) forwards,
+ pl-fill 0.55s ease calc(var(--d0) + 0.65s) forwards;
+}
+
+/* 2. the facets trace in and rise into place, left → right */
+.pl-facet .pl-shape {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation:
+ pl-draw 0.6s ease-out calc(var(--d0) + 0.45s + var(--i) * 0.08s)
+ forwards,
+ pl-fill 0.45s ease calc(var(--d0) + 0.7s + var(--i) * 0.08s)
+ forwards,
+ pl-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1)
+ calc(var(--d0) + 0.45s + var(--i) * 0.08s) both;
+}
+
+/* 3. while waiting: a signal runs along the facets */
+.pl-facet {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: pl-wave 1.8s ease-in-out
+ calc(var(--d0) + 1.35s + var(--i) * 0.11s) infinite;
+}
+
+@keyframes pl-draw {
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+
+@keyframes pl-fill {
+ to {
+ fill-opacity: 1;
+ }
+}
+
+@keyframes pl-rise {
+ from {
+ transform: translateY(70px);
+ }
+ to {
+ transform: none;
+ }
+}
+
+@keyframes pl-wave {
+ 0%,
+ 45%,
+ 100% {
+ transform: none;
+ color: #fff;
+ }
+ 20% {
+ transform: translateY(-46px);
+ color: #34d399;
+ }
+}
+
/* Hero graphics (