Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 86 additions & 62 deletions app/components/background/PageBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,34 @@

import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
import { useIsMobile } from "@/app/hooks/useIsMobile";
import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion";
import { configureScrollTrigger } from "@/app/lib/scrollTrigger";
import {
HERO_SCENE_DATA_ATTR,
MISSION_STATEMENT_DATA_ATTR,
PAGE_BG_DARKEN,
PAGE_BG_SPONSORS_LIGHTEN,
PAGE_BG_MOBILE_WHITEOUT_SCRUB,
PAGE_BG_WHITEOUT,
SPONSORS_SECTION_DATA_ATTR,
dispatchNavbarThemeOverride,
type NavbarTheme,
} from "../navbar/navbarThemeOverride";
import {
NAVBAR_LIGHT_THRESHOLD,
PAGE_BG_PHASES,
PAGE_BG_SMOOTHING,
PAGE_BG_SNAP_DELTA,
} from "./sceneConfig";

configureScrollTrigger();

/**
* Owns the page background and the navbar light/dark theme.
*
* The light layer's opacity is a pure function of scroll position: each
* phase gets a plain ScrollTrigger (no tween) and on every update the last
* phase in page order with progress > 0 determines the value. A single
* quickTo writer applies it with a short catch-up for the scrub feel, so
* there are never competing tweens fighting over the same property and the
* background can't get stuck in the wrong state after a fast scroll.
*/
export default function PageBackground() {
const lightLayerRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
Expand All @@ -30,71 +42,83 @@ export default function PageBackground() {
return;
}

gsap.set(lightLayer, { opacity: 0 });

if (prefersReducedMotion) {
gsap.set(lightLayer, { opacity: 0 });
// Reduced-motion fallbacks paint their own section backgrounds and
// declare data-navbar-theme, which useNavbarTheme observes directly.
return;
}

gsap.set(lightLayer, { opacity: 0 });
const smoothing = isMobile
? PAGE_BG_SMOOTHING.mobile
: PAGE_BG_SMOOTHING.desktop;
const setOpacity = gsap.quickTo(lightLayer, "opacity", {
duration: smoothing,
ease: "none",
});

let navbarTheme: NavbarTheme | null = null;

const phases: {
from: number;
to: number;
easeFn: gsap.EaseFunction;
trigger: ScrollTrigger;
}[] = [];

const heroScene = document.querySelector<HTMLElement>(
`[${HERO_SCENE_DATA_ATTR}]`,
);
const missionStatement = document.querySelector<HTMLElement>(
`[${MISSION_STATEMENT_DATA_ATTR}]`,
);
const sponsorsSection = document.querySelector<HTMLElement>(
`[${SPONSORS_SECTION_DATA_ATTR}]`,
);

const whiteoutScrub = isMobile
? PAGE_BG_MOBILE_WHITEOUT_SCRUB
: PAGE_BG_WHITEOUT.scrub;

if (heroScene) {
gsap.to(lightLayer, {
opacity: 1,
ease: PAGE_BG_WHITEOUT.ease,
scrollTrigger: {
trigger: heroScene,
start: PAGE_BG_WHITEOUT.start,
end: PAGE_BG_WHITEOUT.end,
scrub: whiteoutScrub,
},
for (const phase of PAGE_BG_PHASES) {
const el = document.querySelector<HTMLElement>(`[${phase.attr}]`);
if (!el) {
continue;
}

phases.push({
from: phase.from,
to: phase.to,
easeFn: gsap.parseEase(phase.ease),
trigger: ScrollTrigger.create({
trigger: el,
start: phase.start,
end: phase.end,
onUpdate: () => update(),
onRefresh: () => update(),
}),
});
}

if (missionStatement) {
gsap.fromTo(
lightLayer,
{ opacity: 1 },
{
opacity: 0,
ease: PAGE_BG_DARKEN.ease,
immediateRender: false,
scrollTrigger: {
trigger: missionStatement,
start: PAGE_BG_DARKEN.start,
end: PAGE_BG_DARKEN.end,
scrub: PAGE_BG_DARKEN.scrub,
},
},
);
}
function update() {
let value = 0;
for (const phase of phases) {
const { progress } = phase.trigger;
if (progress > 0) {
value = gsap.utils.interpolate(
phase.from,
phase.to,
phase.easeFn(progress),
);
}
}

if (sponsorsSection) {
gsap.to(lightLayer, {
opacity: 1,
ease: PAGE_BG_SPONSORS_LIGHTEN.ease,
immediateRender: false,
scrollTrigger: {
trigger: sponsorsSection,
start: PAGE_BG_SPONSORS_LIGHTEN.start,
end: PAGE_BG_SPONSORS_LIGHTEN.end,
scrub: PAGE_BG_SPONSORS_LIGHTEN.scrub,
},
});
const current = Number(gsap.getProperty(lightLayer, "opacity"));
if (Math.abs(value - current) >= PAGE_BG_SNAP_DELTA) {
gsap.set(lightLayer, { opacity: value });
}
setOpacity(value);

const nextTheme: NavbarTheme =
value >= NAVBAR_LIGHT_THRESHOLD ? "light" : "dark";
if (nextTheme !== navbarTheme) {
navbarTheme = nextTheme;
dispatchNavbarThemeOverride(nextTheme);
}
}

update();

return () => {
dispatchNavbarThemeOverride(null);
};
},
{ dependencies: [isMobile, prefersReducedMotion], revertOnUpdate: true },
);
Expand Down
96 changes: 69 additions & 27 deletions app/components/background/sceneConfig.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,81 @@
/**
* Page-level background crossfade config.
*
* A single fixed background element is painted once and its light layer's
* opacity is driven by scrubbed tweens:
* - WHITEOUT: dark → light, scoped to the Hero section
* - DARKEN: light → dark, scoped to the Mission statement section
* - SPONSORS_LIGHTEN: dark → light, scoped to the Timeline/Sponsors handoff
* A single fixed background element is painted once. Its light layer's
* opacity is computed deterministically from the scroll position by
* PageBackground: the phases below are evaluated in page order and the
* last phase with progress > 0 owns the value. This guarantees the
* background always matches the scroll position, no matter how fast the
* user scrolls in either direction.
*
* Timings match what Hero and Mission previously controlled locally, so the
* visual rhythm is unchanged — only the owning DOM node is unified.
* The navbar light/dark theme is derived from the same value, so the
* background and navbar can never disagree.
*/

export const PAGE_BG_WHITEOUT = {
start: "70% bottom",
end: "bottom bottom",
ease: "power2.in",
scrub: 0.2,
} as const;
export const HERO_SCENE_DATA_ATTR = "data-bg-hero-scene";
export const MISSION_STATEMENT_DATA_ATTR = "data-bg-mission-statement";
export const TIMELINE_SECTION_DATA_ATTR = "data-bg-timeline-section";

export const PAGE_BG_DARKEN = {
start: "center top",
end: "bottom top",
ease: "power1.in",
scrub: 1,
} as const;
export type PageBgPhase = {
/** Data attribute identifying the section that drives this phase */
attr: string;
/** Light-layer opacity at phase progress 0 */
from: number;
/** Light-layer opacity at phase progress 1 */
to: number;
start: string;
end: string;
ease: string;
};

export const PAGE_BG_SPONSORS_LIGHTEN = {
start: "top bottom",
end: "top center",
/**
* Dark → light before the Sponsors section arrives. Completing the
* crossfade while the Timeline still fills the viewport (rather than
* while the opaque Sponsors edge is already visible) is what prevents
* the hard seam line at the section boundary.
*/
export const TIMELINE_LIGHTEN_PHASE = {
attr: TIMELINE_SECTION_DATA_ATTR,
from: 0,
to: 1,
start: "72% bottom",
end: "96% bottom",
ease: "power1.out",
scrub: 1,
} as const satisfies PageBgPhase;

/** Phases in page order: hero whiteout → mission darken → timeline lighten */
export const PAGE_BG_PHASES: readonly PageBgPhase[] = [
{
attr: HERO_SCENE_DATA_ATTR,
from: 0,
to: 1,
start: "70% bottom",
end: "bottom bottom",
ease: "power2.in",
},
{
attr: MISSION_STATEMENT_DATA_ATTR,
from: 1,
to: 0,
start: "center top",
end: "bottom top",
ease: "power1.in",
},
TIMELINE_LIGHTEN_PHASE,
] as const;

/** Seconds for the light layer to catch up to the computed value (scrub feel) */
export const PAGE_BG_SMOOTHING = {
desktop: 0.2,
mobile: 0.6,
} as const;

export const PAGE_BG_MOBILE_WHITEOUT_SCRUB = 0.6;
/**
* When a scroll jump moves the computed value by more than this in one
* update (e.g. Home/End or a fast fling), the layer snaps instead of
* easing so an opaque section edge is never crossed mid-catch-up.
*/
export const PAGE_BG_SNAP_DELTA = 0.35;

export const HERO_SCENE_DATA_ATTR = "data-bg-hero-scene";
export const MISSION_STATEMENT_DATA_ATTR = "data-bg-mission-statement";
export const SPONSORS_SECTION_DATA_ATTR = "data-bg-sponsors-section";
/** Light-layer opacity at which the navbar switches to its light theme */
export const NAVBAR_LIGHT_THRESHOLD = 0.5;
13 changes: 12 additions & 1 deletion app/components/footer/FooterReveal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,25 @@ export default function FooterReveal() {
onLeaveBack: () => updateFooterState(false, false),
});

const resizeObserver = new ResizeObserver(() => {
let lastFooterHeight = footer.offsetHeight;
const debouncedRefresh = gsap.delayedCall(0.2, () => {
syncSpacerHeight();
ScrollTrigger.refresh();
});
debouncedRefresh.pause();

const resizeObserver = new ResizeObserver(() => {
if (footer.offsetHeight === lastFooterHeight) {
return;
}
lastFooterHeight = footer.offsetHeight;
debouncedRefresh.restart(true);
});
resizeObserver.observe(footer);

return () => {
resizeObserver.disconnect();
debouncedRefresh.kill();
trigger.kill();
};
},
Expand Down
Loading