Dev notes
Scroll-driven, section-by-section, zero new dependencies — then extended with a WebGL ShaderGradient hero and interactive mouse parallax.
The constraint was no Framer Motion, no GSAP, no animation library. Scroll animations use a useInView hook wrapping IntersectionObserver. The observer is one-shot — once an element becomes visible it disconnects, so animations only fire once. A reveal() helper toggles Tailwind classes between opacity-0 translate-y-8 and opacity-100 translate-y-0 with a 700ms CSS transition.
The hero entrance uses pure CSS @keyframes with animation-fill-mode: forwards and staggered animation-delay values. No JS state needed — the elements start at opacity: 0 and the animation drives them to their final state.
page.tsx is a server component. It calls auth0.getSession() — a local cookie decrypt, no network call — and renders either the hub for logged-in users or <LandingContent /> for everyone else. Same URL, different content, no redirect to /protected.
LandingContent is a thin orchestrator. Each of the six sections is its own component under src/app/landing/, each owning its own scroll observer, markup, and data. Shared utilities like useInView, reveal(), and the Section wrapper live in the same folder.
Every color uses the project's design token system — semantic classes like bg-background and text-foreground resolve to CSS custom properties that swap based on data-theme. Sections that need to be the opposite of the current theme use explicit palette tokens like bg-neutral-950 dark:bg-neutral-100. Feature cards use the group hover pattern — the gradient layer transitions from opacity-0 to opacity-100 on hover. No event handlers, no state — pure CSS.
The hero background — and every section below it — runs an interactive water ripple simulation in a <canvas>. The physics is the discrete 2D wave equation: each cell's new height is the average of its four neighbors minus its previous height, then damped by h -= h >> 5 (multiply by 31/32 per frame). Two Int32Array buffers double-buffer the simulation — no float allocation in the hot loop. The simulation runs at 1/3 canvas resolution and is bilinear-upscaled via drawImage to fill the element.
Rendering converts height-field gradients into a surface normal and applies two-light Phong shading: a cool moonlight key from upper-left (high-exponent specular for tight glints) and a warm rim fill from upper-right. The four color stops — base, diffuse, spec1, spec2 — are a prop, so every section can express a distinct color while sharing the same physics. Mouse position is tracked on window and converted to canvas-local coords via getBoundingClientRect. An IntersectionObserver pauses each section's RAF loop when scrolled off-screen, so at most two or three simulations are active at any time.
The Section wrapper accepts a waterColors prop. When provided it renders the WaterRipple canvas as an absolute inset-0 background, a bg-black/52 veil for legibility, and sets data-theme="dark" on the element so all CSS token variables (text-foreground, text-muted, etc.) resolve to their light/readable dark-mode values. No text colors had to be changed in any section file — one attribute does all the token remapping.
The landing page background switched from per-section water ripple canvases to a single fixed WeatherCanvas that picks one of six effects (rain, clear, storm, snow, clouds, fog) based on the visitor's real weather. An IntersectionObserver pauses the RAF loop when the canvas scrolls out of view, so the sim only burns CPU while someone can actually see it.
The snow and cloud effects originally allocated createRadialGradient per particle per frame: 260 flakes at 60 fps is 15k+ gradient objects per second. Now both effects pre-render sprites to offscreen canvases at init time and drawImage them each frame. Zero per-frame allocation.
The wave propagation, double-buffering, and Phong shading code was duplicated between WaterRipple and the rain weather effect. That's now extracted into a shared WaveSim class in waveSim.ts. Both consumers configure it differently (different disturbance radii, different drop patterns) but share the same hot loop.
Seven section files were each defining identical headingWipe and fadeUp Framer Motion variant objects. Those now live in animations.ts alongside the other shared presets. The old custom useInView hook was also retired. Every section now uses Framer Motion's useInView for consistency, including the footer which was the last holdout.
A new matchup page at /fantasy/nba/matchups shows head-to-head weekly matchups pulled from the ESPN fantasy API. The schedule is a flat array where every teamsCount / 2 entries make one week, so playoff weeks are derived by comparing the week number against matchupPeriodCount from the schedule settings. Each matchup card shows total points, a category breakdown across seven stat columns, and an animated win probability bar using Framer Motion's useSpring. A shared FantasyNav tab bar now links Matchups, League History, and Player Stats so you can move between the three ESPN pages without going back to the hub.
The Player Stats page now has a comparison tool built with recharts. Two dropdowns let you pick players from the loaded roster, and a RadarChart plots six dimensions (PTS, REB, AST, STL, BLK, FG%) normalized to a 0-100 scale where 100 equals twice the league average. The normalization lives in fantasyHelpers.ts so it can be reused elsewhere. A raw stat table below the chart highlights category winners in the same orange/cyan scheme used by the matchup cards.
An SVG half-court at /fantasy/nba/court-vision renders six shooting zones whose fill color maps to FG%: blue for cold, yellow for average, red for hot. Each zone is a <path> with Framer Motion staggered fade-in and a hover tooltip showing the exact percentage and attempts per game. The backend endpoint currently returns deterministic mock data seeded by player ID because the NBA's shotchartdetail endpoint times out from server environments. The response shape is ready for a drop-in swap once access is sorted.
A “Prediction for” selector in the matchup toolbar reveals a predictions widget below the matchup grid. It has four sections, each with a left-border accent heading: Start/Sit recommendations rank every rostered player by a projected-points model that factors in opponent defensive ranking, with a color-coded confidence bar per row. Waiver Wire surfaces bench players from other teams that project higher than yours. Weekly Outlook gives a 1-5 star rating and a short summary of matchup difficulty. Injury Watch lists anyone on your roster with an ESPN injury flag (DTD, OUT, Questionable, Doubtful). Everything is algorithmic using a deterministic seeded random so the same player always gets the same projection, no external AI involved.
Two landing sections now have interactive 3D models: the NBA section has a rotating basketball and the auth section has an oscillating padlock. Both use @react-three/fiber with a shared SectionModelScene canvas that sets frameloop="demand" so the GPU only works when OrbitControls or a useFrame animation is active. Each canvas is dynamically imported with ssr: false and wrapped in a ModelLazyMount IntersectionObserver that defers WebGL context creation until the section is 200px from the viewport. Remote HDR environment maps were replaced with explicit ambientLight + directionalLight primitives to eliminate the network dependency.
The basketball canvas is positioned absolutely with left: "52%"; right: "-20vw" so the ball bleeds off the right edge of the viewport, clipped by overflow-x: clip on the body. Two non-obvious constraints had to be solved. The text content div uses md:w-[52%] rather than padding — padding would extend the element's hit area over the canvas, causing text selection on drag. The canvas wrapper is pointer-events-none but the R3F Canvas sets pointerEvents: "auto" explicitly to override the inherited value and give OrbitControls a clean event surface.
Feature highlights are a plain-HTML carousel — not R3F Html overlays. Three.js Html positions elements in world space, so they orbit with the camera rather than staying fixed on screen. The carousel uses AnimatePresence for slide transitions and pill-shaped <button> dot indicators — active dot is wider ( w-5) via a CSS transition, no JS animation needed.
The lock model sits centered at the bottom of the auth section — below the text and code snippet. It uses a pendulum animation: a useFrame callback drives rotation.y = Math.sin(elapsed * 0.35) * 0.45 on an outer group (±26° at 0.35 Hz), while a <Float> inside adds a slow vertical bob with no additional rotation. OrbitControls remains active so users can drag to inspect the model; autoRotate is off to avoid conflicting with the pendulum.
Both GLBs were exported with Draco mesh compression (KHR_draco_mesh_compression), which requires a WASM decoder at runtime. That decoder needs 'wasm-unsafe-eval' in script-src and blob: in img-src and connect-src (Three.js creates blob URLs for embedded textures). Rather than carry that CSP surface area, the GLBs were stripped of compression using @gltf-transform/core + draco3d as a one-time offline step. The uncompressed files load with the default GLTFLoader and no runtime decoder.
One last catch: Three.js's loader cache (THREE.Cache) keys entries by URL. Earlier failed decode attempts left stale error entries under /models/basketball.glb and /models/lock.glb. Even after the files were fixed, the cache returned the old failure. Adding ?v=2 to both URLs gave each a fresh cache key without touching the files on disk.
Two more sections got R3F models in Phase 5. The GraphQL section uses a procedural model: the official GraphQL logo — a regular hexagon outer ring plus an equilateral triangle connecting every other vertex (12, 4, and 8 o'clock). Six sphere nodes sit at each hex vertex, all in #e535ab — the GraphQL brand pink. The canvas renders full-bleed at 30% CSS opacity so it acts as a depth layer behind the query inspector and text rather than competing with it. Because the cluster rotates continuously, frameloop="always" is required (demand mode would never re-render). The canvas is pointer-events: none — no hotspot interactivity; the three feature cards in the section cover the same information.
The Vitals section loads a speedometer GLB with an animated needle that lerps from a resting angle to the “good” zone on scroll entry via useFrame. The layout was restructured: speedometer canvas centered at the top (360px tall, max-width 520px), three primary stat cards (LCP, INP, CLS) in a grid-cols-3 row below, then the existing feature highlights and CTA.
The speedometer GLB loaded (Suspense resolved, no more orange sphere) but was invisible — only the hotspot dots were visible. Inspecting the GLB revealed bounding box coordinates like X: −30000 to 8000, Z: −63000 to −24000. The model existed miles from the camera at any fixed scale value.
The fix is a Box3 auto-fit run in useEffect after the cloned scene is available. Compute the bounding box, extract size and center, then set group.scale = TARGET_SIZE / maxDimension and group.position = -center * scale. The model now centers at the world origin regardless of its native coordinate system — a general-purpose pattern for any GLB with unknown units.
The speedometer GLB initially showed the orange Suspense fallback sphere. Same symptom as the basketball and lock models — but a different root cause. The file had previously been optimized with gltf-transform optimize which applies Draco compression by default, compressing the 55KB raw file down to 5.5KB. The project intentionally avoids Draco to keep the CSP clean (no wasm-unsafe-eval, no blob:), so GLTFLoader had no decoder and silently fell back to the Suspense fallback. Fix: gltf-transform optimize --compress false, producing a 45KB plain GLB. Bumping the URL from ?v=2 to ?v=3 cleared the stale entry from THREE.Cache and the browser HTTP cache — same cache-bust pattern as the earlier models.
A quality pass across all fantasy pages. A useCountUp hook animates numbers from zero using requestAnimationFrame with ease-out cubic easing, and skips the animation entirely when the user has prefers-reduced-motion enabled. The Player Stats table gained an FPT column for fantasy points. Responsive fixes include a horizontally scrollable prediction table, overflow-safe nav tabs, and 44px touch targets on mobile. Accessibility additions: aria-live regions on all main content areas, aria-labels on interactive controls, and a labeled fantasy nav landmark. Matchups and Court Vision now have cards in the feature hub with mini preview components.
With all seven section models working, the final phase locked down idle behavior and accessibility. Three problems needed solving: the always-on canvases burning GPU while scrolled away, Float and auto-rotation running regardless of motion preferences, and touch users triggering model rotation when they meant to scroll the page.
The existing ModelLazyMount component defers canvas mount until the user is 200px from the section — a one-shot observer that disconnects after first intersection. But once mounted, the canvases continued rendering even when scrolled far away. The hero globe and GraphQL cluster both run frameloop="always", which means a continuous requestAnimationFrame loop even when nothing is visible.
The fix is a PauseWhenOffscreen R3F scene component that attaches an IntersectionObserver directly to gl.domElement — the actual <canvas> element. When the canvas exits the viewport it calls set({ frameloop: 'never' }) on the R3F store, which stops the animation loop entirely. When the canvas comes back into view, it restores the original frameloop. R3F subscribes to store state changes and adjusts the underlying gl.setAnimationLoop call accordingly.
The observer uses rootMargin: "0px" so it fires exactly when the canvas element exits the viewport — not 200px before, which is what ModelLazyMount uses for its lookahead. Two observers, two jobs, one each.
The Float component from Drei gives models a gentle idle bob. Four models use it: basketball, padlock, clock, and speedometer. The straightforward thing is to pass speed={0} when reduced motion is requested — Float still runs its useFrame loop, just with zero time advancement. Better to skip the Float wrapper entirely so the useFrame callback is never registered at all.
Each model that uses Float now computes a disableFloat boolean and conditionally renders either the Float-wrapped content or the raw content. Same JSX tree inside, different outer wrapper. The check covers both prefers-reduced-motion and a mobile breakpoint — Float at 1x DPR on a small screen is wasted work since the motion is barely perceptible anyway.
A note on framer-motion inside R3F: R3F uses react-reconciler to create a separate fiber tree for the canvas. Framer Motion's useReducedMotion reads from its internal MotionConfigContext, which isn't propagated into R3F's fiber. Models read window.matchMedia("(prefers-reduced-motion: reduce)") directly — same underlying browser API, no context dependency. Standard React hooks (useState, useEffect) work fine inside R3F components since those operate through React's dispatcher, not the host element type system.
OrbitControls defaults to one-finger rotate on touch. A user scrolling the page with a single finger would accidentally spin whichever model their finger crossed. The fix: map the ONE-finger touch action to PAN (which is disabled via enablePan={false}) so single-touch does nothing. TWO-finger gestures use DOLLY_ROTATE — with zoom disabled that reduces to pure rotation, letting users deliberately spin a model with two fingers without triggering page pinch-zoom.
The Drei OrbitControls component exposes the touches property from THREE's OrbitControls, but JSX prop types can vary across drei versions. Setting it imperatively via ref in a useEffect is the safe fallback that works regardless.
The weather canvas started as the sole atmospheric layer — a single full-bleed 2D canvas filling the page background with rain, snow, or sun based on the visitor's location. It does its job, but weather alone is passive. You watch it; it doesn't know you're there.
The 3D models invert that. They respond to cursor drag, show hotspot tooltips on hover, animate on scroll entry. They're foreground interactive content, not background decoration. The basketball feels like you can pick it up and inspect it.
Both together is better than either alone because they occupy different perceptual layers. The weather canvas provides ambient depth — your eye reads it as "the environment the content lives in." The 3D models live in that environment as objects you interact with. The visual hierarchy is atmospheric layer → section card → 3D model → hotspot, each at a different Z-depth in both the literal stacking context and the user's attention.
One concern was performance: two graphics systems running simultaneously. The answer is that they don't actually overlap in time. The weather canvas is a 2D <canvas> painted by a wave-propagation loop in software; the 3D models are WebGL rendered through R3F. They run on separate contexts with separate paint cycles. The 2D canvas is CPU-bound; the WebGL canvases hand off to the GPU. Neither waits for the other.
The landing page now has eight WebGL contexts: one hero globe, one GraphQL cluster, one vitals speedometer, and five section canvases (NBA, auth, calendar, TCG, and vitals again). Without lifecycle management this would be expensive. The actual cost per idle canvas is zero because of two compounding guards.
Guard one: ModelLazyMount. The WebGL context doesn't even exist until the section is 200px from the viewport. Every section below the fold has no canvas, no context, no memory until the user scrolls near it.
Guard two: frameloop="demand" on the five section canvases. R3F only renders a frame when invalidate() is called — which happens when the user drags OrbitControls or when a Float animation emits. A section that's mounted but not interacted with does not paint.
Guard three: PauseWhenOffscreen on all eight canvases. When the user scrolls past a section, the observer fires, frameloop switches to "never", and the animation loop stops completely. The WebGL context is still alive (no allocation/deallocation churn), but the RAF stops. The always-on canvases (hero, GraphQL, vitals) are the ones that actually needed this: they have no demand trigger, so without the pause they'd spin forever at 60fps even while five other sections are in view.
In practice: at any scroll position, at most two or three canvases are active. The section currently centered on screen has its canvas rendering. Sections 200px above or below have their canvas mounted but paused. Everything else has no canvas at all. The page feels alive everywhere you look, but the hardware work is proportional to what's actually visible.