A scroll-driven WebGL journey from a single star out to the Laniakea
Supercluster and back down onto one named star, built to the brief in
Astronamer_Website_Design_Prompt.md.
The site is not a page describing the app. It is a compressed run of the app's own universe-to-single-star navigation, in the browser, before you install anything.
npm run dev # http://localhost:3000
npm run build # fully static export — no server runtime needed
npm run lint
npx tsc --noEmit| Choice | Why |
|---|---|
| Next.js 16 (App Router) | The whole page prerenders to static HTML. Copy, metadata and the CSS sky are painted before a byte of three.js parses. |
| React 19 + React Three Fiber 9 | Declarative scene graph. The 3D reads as components with props, not as an imperative graph you have to hold in your head. |
| three.js | Everything is a custom ShaderMaterial and a GPU point cloud. No model loader, no physics, no scene format. |
| GSAP + ScrollTrigger | The only scroll library that measures reliably across mobile browser-chrome resize. Free in GSAP 3.13+. |
| Zustand | UI state only. Per-frame state deliberately does not live here — see below. |
| Tailwind v4 | Layout and spacing utilities. Design tokens live in @theme in globals.css; bespoke visuals are hand-written CSS classes prefixed u-. |
| TypeScript, strict |
The site ships no 3D model files, no image assets, and no audio files. Stars, galaxies, nebulae, the certificate face and the ambient audio bed are all generated at runtime. That is not a shortcut — it is why the whole thing loads fast, scales cleanly to any resolution, and cannot break because a file failed to deploy.
You cannot travel from 8 light-years to 520 million light-years in float32. The depth buffer falls apart long before you get there.
So the camera never really goes anywhere. It stays inside a small, well-conditioned envelope, and the world scales around it. Three "cosmic levels" sit at the same origin:
| Level | center |
Renders 1:1 when |
|---|---|---|
| Local star field | 0 | at the hero and every close-up section |
| Milky Way | 1 | mid pull-back |
| Laniakea Supercluster | 2 | the widest view |
Each is scaled by SCALE_STEP^(center − scale) and neighbours cross-fade, so
exactly one is at natural size at a time. It reads as a powers-of-ten zoom while
every vertex stays within ~1e3 units of the camera.
src/animations/journey.ts · src/scenes/CosmicLevel.tsx
Two kinds of state, kept strictly apart:
frame(src/animations/state.ts) — a plain mutable object. Scroll and pointer listeners write to it;useFramecallbacks read it. Never triggers a render. At 60fps a store update per frame would re-render the entire overlay tree.useAppStore— ordinary Zustand, for things that genuinely change the UI: boot phase, active section, audio on/off, the claimed star name.
Position on the journey is one number: u = sectionIndex + progressWithinSection,
so u ∈ [0, 7] across the seven sections. Every camera keyframe, every object's
visibility window, and the warp transition are all placed on that one axis.
Because section heights in the DOM set the pacing, copy can grow without retuning a single camera value.
src/
animations/
journey.ts The camera keyframes. Read top to bottom = the whole film.
state.ts frame (mutable, per-frame) + useAppStore (React state)
useJourneyScroll.ts ScrollTrigger → u. One trigger per section.
useViewportFade.ts Beat copy that fades in and back out
usePointerParallax.ts Cursor + device tilt
scenes/
UniverseCanvas.tsx The single persistent <Canvas> + boot/precompile
Universe.tsx The whole world in one graph
CameraRig.tsx Scroll → damped camera
CosmicLevel.tsx One rung of the scale ladder
StarField.tsx GPU point cloud — hero field, galaxy disc, halo
GalaxyCluster.tsx ~1,600 galaxies in one instanced draw call
NamedStar.tsx Clickable star + floating name card
CertificateCard.tsx 3D card, face drawn to canvas at runtime
AudioReactiveHalo.tsx Ring displaced by the narration spectrum
Constellation.tsx Hairline figures that trace themselves on
Atmosphere.tsx Nebula dust + the warp-streak veil
Effects.tsx Tiered post-processing
visibility.ts Fade windows on the u axis
shaders/ All GLSL
ui/
SiteShell.tsx Orchestrates everything
Section.tsx Section shell + shared type primitives
sections/ One file per section of the brief
LoadingScreen.tsx "Calibrating telescope…" — real progress
StaticFallback.tsx CSS sky, static hero, re-enable-3D control
Nav / Footer / AudioControl / CometCursor
lib/
deviceTier.ts Device probing + per-tier budgets
audioEngine.ts Synthesised ambience, chimes, narration analyser
useMediaQuery.ts Reduced motion, via useSyncExternalStore
content/copy.ts ALL site copy, in one file
assets/higgsfield.ts Generated-asset slot manifest
src/lib/deviceTier.ts probes the device once on the client — reduced-motion
preference, WebGL availability, unmasked GPU string, core count, device memory,
save-data, viewport — and picks a tier. Every scene reads its counts from
TIER_BUDGET rather than hard-coding.
| static | low | mid | high | |
|---|---|---|---|---|
| Local stars | — | 3,500 | 7,000 | 14,000 |
| Galaxy stars | — | 9,000 | 32,000 | 85,000 |
| Cluster galaxies | — | 220 | 700 | 1,600 |
| Bloom | — | ✓ | ✓ | ✓ |
| Depth of field | — | — | — | ✓ |
| Chromatic aberration | — | — | ✓ | ✓ |
| MSAA | — | — | — | ✓ |
| Max DPR | 1 | 1.25 | 1.6 | 2 |
static skips WebGL entirely and renders the CSS starfield. It is selected for
prefers-reduced-motion, no WebGL context, or a software rasteriser.
Shader precompilation. Before the loading screen lifts, BootSequence
forces every level visible, hands the whole graph to gl.compileAsync, then
steps u through all twelve keyframes so each level renders at least once and
the driver uploads its buffers. Compiling a shader when a section first scrolls
into view is exactly the stutter this site cannot afford.
AdaptiveDpr also drops resolution at runtime if the frame budget slips, with
a floor of 0.8x (performance={{ min: 0.8 }}) — unbounded it degrades hardest
exactly at the galaxy beat, which reads as the page suddenly going pixelated.
Counts are lower than they look like they should be on purpose. Once every star carries a real magnitude and a sub-pixel alpha fade, brightness distribution sells the field, not raw density — and every extra point is an additive, depth-write-free fragment on the heaviest frame in the site.
Scrubbing a 10,000px page by hand to check one keyframe is miserable, so dev builds expose a console handle (stripped from production):
__astronamer.goto(1.625) // jump to any point on the journey axis, 0–7
__astronamer.u // where you are now
__astronamer.triggers() // what each section trigger measured
__astronamer.refresh() // re-measure after a layout changeUseful positions: 0 hero · 1.375 local field · 1.625 Milky Way · 1.875
supercluster · 2.1 mid-warp · 2.45 certificate · 4 narration halo · 7
bookend.
__astronamerScene gives you { gl, scene, camera, advance } for inspecting
what the camera is actually looking at.
- Layout. Single column below
lg. The naming panel drops under its copy; tier cards, arcade cabinets and testimonials all stack. - Camera framing. Keyframes carry a lateral
offsetthat pushes the 3D subject to one side so DOM copy can own the other half. Below 900px that offset is multiplied by 0 and subjects re-centre, because the layout has stacked and there is no side to clear. - Parallax. Cursor on desktop; device tilt on mobile. iOS gates
DeviceOrientationbehind a gesture, so permission is requested on the firstpointerdown, never on load. - Comet cursor is off for coarse pointers.
- Section heights are set in
svh, notvh, so mobile browser chrome collapsing does not resize the journey mid-scroll. - Sound control sits above the safe-area inset.
- Verified with no horizontal overflow at 375, 768 and 1440.
- Every fact the canvas renders also exists as real text in the DOM. The canvas
itself is
aria-hidden— it is decorative duplication, not content. prefers-reduced-motiondrops to the static path. A visible Enable 3D universe control lets anyone opt back in — reduced motion should mean "off by default", not "unavailable".- Narration ships captions plus a full transcript, both available whether or not audio is playing.
- Audio is muted by default with an explicit unmute. Nothing touches the AudioContext until the visitor presses it.
- Skip link, one
h1, ordered headings, labelled controls, visible focus rings,aria-liveon the caption and the loading status.
Nothing is required to run. Two drop zones, both with their own README:
public/assets/higgsfield/— every slot the site expects, declared insrc/assets/higgsfield.tswith exact filename, aspect, duration, the component that consumes it, and prompt direction. Drop a file in and it appears; every consumer falls back to procedural art when it is missing.public/audio/— only the narration sample. Ambience and chimes are synthesised.
- Replace testimonials, the registry counter, ratings and press logos in
src/content/copy.ts— all markedPLACEHOLDER. - Confirm the pricing tiers match the real product.
- Point
SITE_URLinsrc/app/layout.tsxandDOWNLOAD_URLinsrc/app/page.tsxat production. - Real App Store / Play Store links in
copy.ts. - Drop
og-hero.jpg— social cards are currently 404ing. - Record and drop
public/audio/narration-sirius.mp3, then update the caption cue points. - Have legal read
naming.disclosureandfooter.disclosure. Keep them honest. The IAU does not sell star names and no registry entry changes that. Being the star registry that says so plainly is the position this entire site is built on — do not let it get softened into the same copy every competitor runs.