From 8f2bc9d308d20a8bc47d683ffcc860ef8732bcc1 Mon Sep 17 00:00:00 2001 From: Adrian Osorio Blanchard Date: Mon, 22 Jun 2026 16:51:05 -0600 Subject: [PATCH 1/2] Optimize club site loading and scroll performance --- .../src/app/_components/club-content-page.tsx | 4 +- .../app/_components/club-motion-runtime.tsx | 266 ++++++++++++------ apps/club/src/app/_components/footer.tsx | 7 +- .../_components/home-community-carousel.tsx | 1 - apps/club/src/app/_components/home-events.tsx | 44 ++- apps/club/src/app/_components/navbar.tsx | 56 +++- apps/club/src/app/_components/redirect.tsx | 6 +- apps/club/src/app/about/page.tsx | 4 +- apps/club/src/app/events/events-client.tsx | 43 ++- apps/club/src/app/globals.css | 15 +- .../src/app/hackathons/hackathon-history.tsx | 6 +- apps/club/src/app/layout.tsx | 5 + apps/club/src/app/not-found.tsx | 4 +- .../club/src/app/sponsors/sponsors-client.tsx | 1 - apps/club/src/app/teams/teams-client.tsx | 41 ++- 15 files changed, 372 insertions(+), 131 deletions(-) diff --git a/apps/club/src/app/_components/club-content-page.tsx b/apps/club/src/app/_components/club-content-page.tsx index c0b87fba0..b29c6b60e 100644 --- a/apps/club/src/app/_components/club-content-page.tsx +++ b/apps/club/src/app/_components/club-content-page.tsx @@ -69,7 +69,9 @@ function ContentButton({ href, label, variant = "gold" }: ContentLink) { {content} ) : ( - {content} + + {content} + )} ); diff --git a/apps/club/src/app/_components/club-motion-runtime.tsx b/apps/club/src/app/_components/club-motion-runtime.tsx index 5cdbb320d..709bffc17 100644 --- a/apps/club/src/app/_components/club-motion-runtime.tsx +++ b/apps/club/src/app/_components/club-motion-runtime.tsx @@ -9,6 +9,26 @@ const HISTORY_TIMELINE_SELECTOR = "[data-history-timeline]"; const HISTORY_MARKER_SELECTOR = "[data-history-marker]"; const TILT_SELECTOR = "[data-tilt]"; +interface DriftState { + element: HTMLElement; + documentTop: number; + height: number; + depth: number; +} + +interface HeroState { + element: HTMLElement; + documentTop: number; + height: number; +} + +interface TimelineState { + element: HTMLElement; + markers: HTMLElement[]; + documentTop: number; + height: number; +} + function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } @@ -26,6 +46,58 @@ export default function ClubMotionRuntime() { const observedElements = new WeakSet(); const revealElements = new Set(); + let driftElements: DriftState[] = []; + let heroElements: HeroState[] = []; + let timelines: TimelineState[] = []; + let scrollableHeight = 0; + + function getDocumentTop(element: HTMLElement) { + let documentTop = 0; + let offsetElement: HTMLElement | null = element; + + while (offsetElement) { + documentTop += offsetElement.offsetTop; + offsetElement = offsetElement.offsetParent as HTMLElement | null; + } + + return documentTop; + } + + function refreshDocumentMetrics() { + scrollableHeight = Math.max( + document.documentElement.scrollHeight - window.innerHeight, + 0, + ); + } + + function refreshScrollTargets() { + refreshDocumentMetrics(); + driftElements = Array.from( + document.querySelectorAll(DRIFT_SELECTOR), + ).map((element) => ({ + element, + documentTop: getDocumentTop(element), + height: Math.max(element.offsetHeight, 1), + depth: Number(element.dataset.scrollDrift ?? 12), + })); + heroElements = Array.from( + document.querySelectorAll(HERO_SELECTOR), + ).map((element) => ({ + element, + documentTop: getDocumentTop(element), + height: Math.max(element.offsetHeight, 1), + })); + timelines = Array.from( + document.querySelectorAll(HISTORY_TIMELINE_SELECTOR), + ).map((timeline) => ({ + element: timeline, + markers: Array.from( + timeline.querySelectorAll(HISTORY_MARKER_SELECTOR), + ), + documentTop: getDocumentTop(timeline), + height: Math.max(timeline.offsetHeight, 1), + })); + } function revealElement(element: Element) { element.classList.add("is-visible"); @@ -47,12 +119,6 @@ export default function ClubMotionRuntime() { }, ); - function isInsideRevealRange(element: Element) { - const rect = element.getBoundingClientRect(); - - return rect.top < window.innerHeight * 1.05 && rect.bottom > 0; - } - function observeElement(element: Element) { if (observedElements.has(element)) return; @@ -60,22 +126,9 @@ export default function ClubMotionRuntime() { element.classList.add("is-motion-observed"); revealElements.add(element); - if (isInsideRevealRange(element)) { - revealElement(element); - return; - } - revealObserver.observe(element); } - function revealVisibleElements() { - for (const element of [...revealElements]) { - if (isInsideRevealRange(element)) { - revealElement(element); - } - } - } - function observeTree(rootNode: ParentNode) { if (rootNode instanceof Element && rootNode.matches(REVEAL_SELECTOR)) { observeElement(rootNode); @@ -87,18 +140,29 @@ export default function ClubMotionRuntime() { } observeTree(document); + refreshScrollTargets(); root.dataset.motion = "ready"; + const hasTiltTargets = document.querySelector(TILT_SELECTOR) !== null; const mutationObserver = new MutationObserver((mutations) => { + let shouldRefreshScrollTargets = false; + for (const mutation of mutations) { + if (mutation.removedNodes.length > 0) { + shouldRefreshScrollTargets = true; + } + for (const node of mutation.addedNodes) { if (node instanceof Element) { observeTree(node); + shouldRefreshScrollTargets = true; } } } - revealVisibleElements(); + if (shouldRefreshScrollTargets) { + refreshScrollTargets(); + } }); mutationObserver.observe(document.body, { @@ -110,10 +174,7 @@ export default function ClubMotionRuntime() { function updateScrollMotion() { scrollFrameId = null; - revealVisibleElements(); - const scrollableHeight = - document.documentElement.scrollHeight - window.innerHeight; const scrollProgress = scrollableHeight > 0 ? window.scrollY / scrollableHeight : 1; @@ -123,74 +184,87 @@ export default function ClubMotionRuntime() { ); const viewportCenter = window.innerHeight / 2; + const scrollY = window.scrollY; - document - .querySelectorAll(DRIFT_SELECTOR) - .forEach((element) => { - const rect = element.getBoundingClientRect(); - const elementCenter = rect.top + rect.height / 2; - const depth = Number(element.dataset.scrollDrift ?? 12); - const normalizedDistance = clamp( - (viewportCenter - elementCenter) / viewportCenter, - -1, - 1, - ); + for (const drift of driftElements) { + const top = drift.documentTop - scrollY; + const bottom = top + drift.height; - element.style.setProperty( - "--club-scroll-drift", - `${(normalizedDistance * depth).toFixed(2)}px`, - ); - }); + if (bottom < -200 || top > window.innerHeight + 200) { + continue; + } - document.querySelectorAll(HERO_SELECTOR).forEach((hero) => { - const rect = hero.getBoundingClientRect(); - const heroHeight = Math.max(rect.height, 1); - const heroProgress = clamp(-rect.top / heroHeight, 0, 1); + const elementCenter = top + drift.height / 2; + const normalizedDistance = clamp( + (viewportCenter - elementCenter) / viewportCenter, + -1, + 1, + ); + + drift.element.style.setProperty( + "--club-scroll-drift", + `${(normalizedDistance * drift.depth).toFixed(2)}px`, + ); + } - hero.style.setProperty("--club-hero-progress", heroProgress.toFixed(3)); - }); + for (const hero of heroElements) { + const top = hero.documentTop - scrollY; + const bottom = top + hero.height; - document - .querySelectorAll(HISTORY_TIMELINE_SELECTOR) - .forEach((timeline) => { - const viewportAnchor = window.innerHeight * 0.5; - const timelineHeight = Math.max(timeline.offsetHeight, 1); - const viewportAnchorDocument = window.scrollY + viewportAnchor; - let timelineDocumentTop = 0; - let offsetElement: HTMLElement | null = timeline; - - while (offsetElement) { - timelineDocumentTop += offsetElement.offsetTop; - offsetElement = offsetElement.offsetParent as HTMLElement | null; - } + if (bottom < 0 || top > window.innerHeight) { + continue; + } - const scrubberY = clamp( - viewportAnchorDocument - timelineDocumentTop, - 0, - timelineHeight, - ); - const timelineProgress = scrubberY / timelineHeight; + const heroProgress = clamp(-top / hero.height, 0, 1); - timeline.style.setProperty( - "--history-timeline-progress", - timelineProgress.toFixed(4), - ); - timeline.style.setProperty( - "--history-timeline-scrubber-y", - `${scrubberY.toFixed(2)}px`, - ); + hero.element.style.setProperty( + "--club-hero-progress", + heroProgress.toFixed(3), + ); + } - timeline - .querySelectorAll(HISTORY_MARKER_SELECTOR) - .forEach((marker) => { - const markerProgress = Number(marker.dataset.historyMarker ?? 0); - - marker.classList.toggle( - "is-history-marker-active", - timelineProgress + 0.018 >= markerProgress, - ); - }); - }); + for (const timeline of timelines) { + const viewportAnchor = window.innerHeight * 0.5; + const viewportAnchorDocument = scrollY + viewportAnchor; + + if ( + viewportAnchorDocument < timeline.documentTop - window.innerHeight || + viewportAnchorDocument > + timeline.documentTop + timeline.height + window.innerHeight + ) { + continue; + } + + const progressY = clamp( + viewportAnchorDocument - timeline.documentTop, + 0, + timeline.height, + ); + const scrubberY = clamp( + progressY, + 21, + Math.max(timeline.height - 21, 21), + ); + const timelineProgress = progressY / timeline.height; + + timeline.element.style.setProperty( + "--history-timeline-progress", + timelineProgress.toFixed(4), + ); + timeline.element.style.setProperty( + "--history-timeline-scrubber-y", + `${scrubberY.toFixed(2)}px`, + ); + + for (const marker of timeline.markers) { + const markerProgress = Number(marker.dataset.historyMarker ?? 0); + + marker.classList.toggle( + "is-history-marker-active", + timelineProgress + 0.018 >= markerProgress, + ); + } + } } function scheduleScrollMotion() { @@ -257,20 +331,30 @@ export default function ClubMotionRuntime() { } window.addEventListener("scroll", scheduleScrollMotion, { passive: true }); - window.addEventListener("resize", scheduleScrollMotion); - window.addEventListener("pointermove", handlePointerMove, { - passive: true, - }); - window.addEventListener("pointerout", handlePointerOut); + function handleResize() { + refreshScrollTargets(); + scheduleScrollMotion(); + } + + window.addEventListener("resize", handleResize); + if (hasTiltTargets) { + window.addEventListener("pointermove", handlePointerMove, { + passive: true, + }); + window.addEventListener("pointerout", handlePointerOut); + } scheduleScrollMotion(); return () => { revealObserver.disconnect(); mutationObserver.disconnect(); window.removeEventListener("scroll", scheduleScrollMotion); - window.removeEventListener("resize", scheduleScrollMotion); - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerout", handlePointerOut); + window.removeEventListener("resize", handleResize); + + if (hasTiltTargets) { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerout", handlePointerOut); + } if (scrollFrameId !== null) { window.cancelAnimationFrame(scrollFrameId); diff --git a/apps/club/src/app/_components/footer.tsx b/apps/club/src/app/_components/footer.tsx index 14ccc268d..a46612ff8 100644 --- a/apps/club/src/app/_components/footer.tsx +++ b/apps/club/src/app/_components/footer.tsx @@ -59,7 +59,11 @@ function FooterAnchor({ ); } - return {children}; + return ( + + {children} + + ); } function FooterSocialLinks({ @@ -141,6 +145,7 @@ export default function Footer({ bladeUrl }: { bladeUrl: string }) {
  • {link.label} diff --git a/apps/club/src/app/_components/home-community-carousel.tsx b/apps/club/src/app/_components/home-community-carousel.tsx index 80705faa3..9963d2ddd 100644 --- a/apps/club/src/app/_components/home-community-carousel.tsx +++ b/apps/club/src/app/_components/home-community-carousel.tsx @@ -93,7 +93,6 @@ function Polaroid({ slide }: { slide: CommunitySlide }) { src={slide.image} alt={slide.imageAlt} fill - priority sizes="(min-width: 1024px) 27rem, (min-width: 768px) 42vw, 84vw" className="object-cover grayscale transition duration-300" style={{ objectPosition: slide.imagePosition ?? "center" }} diff --git a/apps/club/src/app/_components/home-events.tsx b/apps/club/src/app/_components/home-events.tsx index 0d8d1f69f..ce6cc10b7 100644 --- a/apps/club/src/app/_components/home-events.tsx +++ b/apps/club/src/app/_components/home-events.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { ArrowUpRight } from "lucide-react"; @@ -95,11 +95,45 @@ export function HomeEvents({ bladeUrl: string; eventLimit?: number; }) { + const containerRef = useRef(null); const [events, setEvents] = useState([]); const [status, setStatus] = useState("loading"); + const [shouldLoadEvents, setShouldLoadEvents] = useState(false); const safeEventLimit = getSafeEventLimit(eventLimit); useEffect(() => { + const container = containerRef.current; + + if (!container || shouldLoadEvents) return; + + if (!("IntersectionObserver" in window)) { + const fallbackId = globalThis.setTimeout(() => { + setShouldLoadEvents(true); + }, 0); + + return () => globalThis.clearTimeout(fallbackId); + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return; + + setShouldLoadEvents(true); + observer.disconnect(); + }, + { + rootMargin: "700px 0px", + }, + ); + + observer.observe(container); + + return () => observer.disconnect(); + }, [shouldLoadEvents]); + + useEffect(() => { + if (!shouldLoadEvents) return; + const abortController = new AbortController(); async function loadEvents() { @@ -125,7 +159,7 @@ export function HomeEvents({ void loadEvents(); return () => abortController.abort(); - }, [bladeUrl, safeEventLimit]); + }, [bladeUrl, safeEventLimit, shouldLoadEvents]); const homeEvents = useMemo( () => events.slice(0, safeEventLimit), @@ -133,7 +167,7 @@ export function HomeEvents({ ); return ( - <> +
    {status === "loading" ? ( ) : homeEvents.length > 0 ? ( @@ -158,12 +192,12 @@ export function HomeEvents({ size="lg" className="club-button bg-white text-black shadow-[4px_4px_0_var(--club-gold)]" > - + {allEventsLabel}
    - + ); } diff --git a/apps/club/src/app/_components/navbar.tsx b/apps/club/src/app/_components/navbar.tsx index 7d5f78181..b60a436ee 100644 --- a/apps/club/src/app/_components/navbar.tsx +++ b/apps/club/src/app/_components/navbar.tsx @@ -1,7 +1,7 @@ "use client"; import type { MotionStyle, MotionValue, Transition } from "framer-motion"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -11,7 +11,6 @@ import { useMotionValue, useMotionValueEvent, useReducedMotion, - useScroll, useSpring, useTransform, } from "framer-motion"; @@ -66,6 +65,7 @@ function NavLink({ return ( onSelect?.(item.href)} @@ -124,8 +124,8 @@ export default function Navbar({ bladeUrl }: { bladeUrl: string }) { const pathname = usePathname(); const [isMobileOpen, setIsMobileOpen] = useState(false); const [isClickDisabled, setIsClickDisabled] = useState(false); + const previousScrollY = useRef(0); const prefersReducedMotion = useReducedMotion(); - const { scrollY } = useScroll(); const rawNavFadeProgress = useMotionValue(0); const navFadeProgress = useSpring( rawNavFadeProgress, @@ -164,21 +164,48 @@ export default function Navbar({ bladeUrl }: { bladeUrl: string }) { ? { duration: 0 } : { duration: 0.18, ease: "easeOut" }; - useMotionValueEvent(scrollY, "change", (currentScrollY) => { - const previousScrollY = scrollY.getPrevious() ?? currentScrollY; - const scrollDelta = currentScrollY - previousScrollY; + useEffect(() => { + let animationFrameId: number | null = null; + + function updateNavFade() { + animationFrameId = null; + const currentScrollY = window.scrollY; + const scrollDelta = currentScrollY - previousScrollY.current; + previousScrollY.current = currentScrollY; + + if (currentScrollY <= 4 || scrollDelta < -1) { + rawNavFadeProgress.set(0); + return; + } - if (currentScrollY <= 4 || scrollDelta < -1) { - rawNavFadeProgress.set(0); - return; + if (scrollDelta > 1) { + rawNavFadeProgress.set( + Math.min( + 1, + rawNavFadeProgress.get() + scrollDelta / NAV_FADE_DISTANCE, + ), + ); + } } - if (scrollDelta > 1) { - rawNavFadeProgress.set( - Math.min(1, rawNavFadeProgress.get() + scrollDelta / NAV_FADE_DISTANCE), - ); + function scheduleNavFade() { + if (animationFrameId !== null) return; + + animationFrameId = window.requestAnimationFrame(updateNavFade); } - }); + + previousScrollY.current = window.scrollY; + scheduleNavFade(); + window.addEventListener("scroll", scheduleNavFade, { passive: true }); + + return () => { + window.removeEventListener("scroll", scheduleNavFade); + + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + } + }; + }, [rawNavFadeProgress]); useMotionValueEvent(navOpacity, "change", (opacity) => { const shouldDisableClicks = opacity <= 0.5; @@ -212,6 +239,7 @@ export default function Navbar({ bladeUrl }: { bladeUrl: string }) {