A scroll-driven landing page for Cornell's GeoData project team. A voxel-style 3D Earth starts docked in a split hero (copy left, globe right), then takes over the full screen and rotates through five "story beats" — the Air, Water, Rock, Data and Tech subteams — as the user scrolls. Cartoon voxel props (a satellite, Big Ben, a weather balloon, the Cornell clocktower, an algal-bloom-scanning drone) sit on and around the globe. The globe can also be dragged to rotate freely.
- React 19 + TypeScript, bundled with Vite.
- three.js 0.150.1 for all 3D — the voxel globe, cartoon props, lights, starfield, camera.
- Plain inline styles (no CSS framework); fonts are Google Fonts (Space Grotesk, IBM Plex
Sans, IBM Plex Mono), loaded via
<link>tags inindex.html. - oxlint for linting.
npm install
npm run dev # start the dev server with HMR
npm run build # type-check-free production build (tsc is run separately, see below)
npm run preview # preview the production build locally
npm run lint # oxlintTo type-check the whole project:
npx tsc --noEmitindex.html # entry HTML; loads Google Fonts, mounts #root, script -> src/main.tsx
public/
earth-water.png # equirectangular land/water mask sampled by the voxel globe
favicon.svg
src/
main.tsx # ReactDOM root
App.tsx # page shell: header, content sections (projects/impact/partners/join), <Globe/>
Globe.tsx # thin React wrapper: refs + overlay JSX, delegates all 3D/animation to GlobeEngine
globeEngine.ts # framework-agnostic three.js engine (voxel globe, props, scroll+drag, animate loop)
index.css
vite-env.d.ts
GlobeEngine (in globeEngine.ts) has no React dependency — it's a plain class that
takes a <section>, a <canvas>, and a handful of overlay DOM nodes via mount(), and
owns the three.js scene, the scroll listener, the drag handler, and the
requestAnimationFrame loop. Globe.tsx is a thin wrapper: it renders the JSX (hero copy,
five beat cards, progress dots, scroll hint) with typed refs, and hands the underlying DOM
nodes to new GlobeEngine().mount({ ... }) inside a useEffect, calling engine.unmount()
on cleanup.
This separation is intentional — keep new 3D/animation logic in globeEngine.ts, and keep Globe.tsx
limited to refs and markup.
- The
<section>wrapping the globe isheight: 640vh; aposition: stickyinner wrapper pins the<canvas>for the full viewport height while the user scrolls through it. (The page's outer wrapper usesoverflow-x: clip, nothidden— an ancestor withoverflow: hiddensilently becomes the scroll container and breaksposition: sticky.) - A
scrolllistener computes normalized progresspin[0, 1]from the section'sgetBoundingClientRect()vs. its scrollable height. - The
animate()loop (arequestAnimationFrameloop inGlobeEngine) readspevery frame and drives:- Takeover — eased interpolation of the globe's scale and x-position from the hero layout to full-screen-centered, over the first ~18% of scroll.
- Rotation — target yaw/pitch interpolated between beat keyframes (
this.BEATS), applied with a damped follow for smoothness; drag input (userYaw/userPitch) is added on top so manual rotation and scroll-driven rotation coexist. - Overlays — opacity/transform of the hero copy, beat cards, progress dots, and pins,
all derived from
p.
- Voxel Earth (
buildEarth) — ~7,200 points on a Fibonacci sphere, each one cube in a singleTHREE.InstancedMesh(one draw call). Real geography comes frompublic/earth-water.png(equirectangular land/water mask), sampled per-point on an offscreen canvas; if that fails to load, it falls back to proceduralfbmnoise so the page never breaks. - Cartoon voxel props, each planted on the globe surface via
surfaceGroup(local +Y = outward surface normal): a satellite orbiting independently in the scene, Big Ben (Westminster), a balloon tethersonde (Arizona), the Cornell clocktower (Ithaca), and a drone that sweeps across a small algal-bloom lake, "cleaning" it as it passes. - Lighting is ambient + a white key light + a green rim light, plus a soft additive atmosphere sphere and a slowly-rotating starfield.
Tunable parameters worth knowing about (all in globeEngine.ts): N = 7200 (voxel
density), this.BEATS (beat lat/lon/color), bStart/bEnd in animate() (where beats
begin/end within scroll progress), the takeover lerp(0.9, 1.72, …) and 0.18 fraction,
and the damping/drag-sensitivity constants near the top of animate()/addDrag().
- Needs WebGL —
initThree()wraps renderer creation in atry/catch; if it throws, the engine sets an internal "no WebGL" flag and skips the render loop cleanly rather than crashing. earth-water.pngis served same-origin frompublic/, sogetImageDataon the sampling canvas never hits a cross-origin taint issue. If you swap in a different land mask, adjust the brightness threshold (< 110) used to distinguish land from water.
This project was originally authored in a proprietary "Design Component" (.dc.html)
wrapper and later ported to this standalone Vite + React + TypeScript app.