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
+52
View File
@@ -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 <html>, 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 (
<div className="page-loader" aria-hidden="true">
<svg
className="page-loader-logo"
viewBox="0 0 611 1043"
width={56}
height={96}
fillRule="evenodd"
>
<path className="pl-shape pl-letter" d={LETTER} pathLength={1} />
{FACETS.map((d, i) => (
<g
key={i}
className="pl-facet"
style={{ "--i": i } as React.CSSProperties}
>
<path className="pl-shape" d={d} pathLength={1} />
</g>
))}
</svg>
</div>
);
}
+175
View File
@@ -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<string>;
/** 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<void> {
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);
}
+9 -3
View File
@@ -1,11 +1,17 @@
/**
* Inline <head> 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){}})();`;
+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() {
+18 -3
View File
@@ -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 }) => {
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?.();
};
}, []);
+10 -1
View File
@@ -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]);
+15 -1
View File
@@ -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();
+2
View File
@@ -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) {
<Footer />
</div>
<MobileMenu {...{ menuOpen, setMenuOpen }} />
<PageLoader />
</div>
);
}
+131
View File
@@ -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 (<xl): stacked above the text; fades out downward into it */
@media (max-width: 79.999rem) {
.hero-graphic {