how the pilgrimage was built, and how to build your own
The brief was restraint as the flex: a single monolith, and a scroll that does not move the page — it moves you. The document body is just a 720vh empty spacer; the real content is a fixed WebGL canvas where scroll progress drives a camera along a pre-authored path around the stone. Text appears in six short "movements", each timed to a leg of the walk, written like fragments of a recovered expedition log.
The emotional arc is a pilgrimage: approach from far away and low (humility), read the proportions from the flank, get uncomfortably close, cross behind it into the backlight, ascend, and finally look down at it from altitude — the only moment the stars are visible.
The camera is the protagonist. The monolith never moves. Only your position — and therefore your relationship to it — changes.
Dark scenes die when they are flat #000. This one gets depth from four layers of light: an ember glow low on the horizon behind the monolith (so most views render it as a silhouette), a faint cold counter-light on the viewer's side, exponential fog that swallows the ground line, and a fresnel rim on the stone itself so its edge never fully disappears into the night.
Typography: Cormorant Garamond (300) for the whispers — a serif with enough contrast to feel carved — against IBM Plex Mono (300) for the expedition apparatus: labels, coordinates, movement numerals. The serif is always large and quiet; the mono is always tiny and letter-spaced (letter-spacing: .3–.6em). That gap in scale is the whole typographic system.
The proportions are a citation. The slab is 2 × 8 × 18 — exactly 1 : 4 : 9, the squares of the first three integers, the dimensions of the monolith in 2001: A Space Odyssey. Movement II points at it without naming it.
FogExp2(#11141a, 0.0205) — the single most important line. It eats the horizon, scales the mood, and makes the far approach read as "distance" instead of "small box".Two CatmullRomCurve3 splines are authored by hand — one for the camera position, one for its look-at target. Scroll progress (0→1) is smoothed with an exponential lerp and sampled on both curves every frame. Centripetal parameterisation avoids kinks at the tight close-pass keyframe:
// seven keyframes: approach → flank → close pass → far side → ascent → altitude const positions = new THREE.CatmullRomCurve3([ new THREE.Vector3( 0, 2.6, 64), // far, low, humble new THREE.Vector3( 24, 3.2, 42), new THREE.Vector3( 21, 4.6, 11), // the ratio reads new THREE.Vector3(6.5, 3.0, 7.5), // intimate, craning up new THREE.Vector3(-11, 6.0, -14), // into the ember light new THREE.Vector3( -4, 26, -21), new THREE.Vector3( 7, 46, -36) // the shape below ], false, 'centripetal', 0.25); // every frame: smoothP += (targetP - smoothP) * 0.065; // scroll inertia positions.getPoint(smoothP, camera.position); lookAts.getPoint(smoothP, look); camera.lookAt(look);
Three small cruelties make it feel filmed rather than computed: a handheld breath (two slow sines added to position), a faint roll through the turn (rotation.z += sin(p·2π)·0.016), and a focal squeeze — the FOV tightens from 42° to 36° as the camera nears the stone, the way a documentary operator instinctively zooms when something is important.
Each verse is a fixed-position HTML element with data-a/data-b progress windows. Opacity is min(smoothstep-in, smoothstep-out) with a 0.028-wide fade band, plus a small vertical drift so text settles into place instead of switching on.
The monolith's material is where the restraint pays off. It is a single fragment shader, four lighting terms added to a near-black base. The striations are value-noise fbm stretched 100:1 so they read as vertical brushing; the fresnel rim is tinted warm on the ember side and cold on the viewer side by mixing on -N.z:
// near-black basalt + brushed striations vec3 base = vec3(0.045, 0.050, 0.058); float stri = fbm(vec2(vUv.x * 140.0, vWp.y * 1.4)); base += (stri - 0.5) * 0.022; // warm key from the dying horizon behind (-z) vec3 Lkey = normalize(vec3(0.12, 0.30, -1.0)); vec3 key = max(dot(N, Lkey), 0.0) * vec3(0.78, 0.62, 0.38) * 0.34; // the edge that refuses to be black float fr = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 3.5); vec3 rim = mix(vec3(0.55, 0.63, 0.74), // cold, viewer side vec3(0.86, 0.72, 0.46), // warm, ember side 0.5 + 0.5 * -N.z) * fr * 0.55; gl_FragColor = vec4(base + key + fill + sky + rim, 1.0); #include <fog_fragment>
Both custom materials set fog: true and splice Three's fog_pars/fog_vertex/fog_fragment chunks, with UniformsLib.fog cloned into their uniforms — so hand-written GLSL still participates in the scene fog. The ground shader works the same way; its rings are smoothstep(0.90, 1.0, sin(r·0.85)) faded by two opposing smoothsteps so they exist only in a band around the base.
The "recovered footage" feel is three cheap layers on top of the render:
background-size: 96px on a 300%-sized fixed element, jittered by a 4-step CSS keyframe animation at mix-blend-mode: overlay, opacity 0.06. It sits above everything — scene, verses, even the field notes — so the whole document reads as one piece of footage. Zero JS per frame.rgba(4,5,7,.55) at the corners. Pulls every composition toward the middle.ACESFilmicToneMapping at exposure 1.05, which rolls the additive ember glow off filmically instead of clipping.All glow sprites are canvas-generated radial gradients (no image assets anywhere on the site — every texture is procedural). No AI-generated assets were used.
devicePixelRatio capped at 2; a single render pass, no postprocessing chain — grain and vignette are DOM, which the compositor handles for free.prefers-reduced-motion: scroll smoothing snaps 1:1, handheld sway / dust drift / grain jitter / loader choreography are all disabled. The pilgrimage still works — it just walks calmly.webglcontextlost → preventDefault()) so the page survives GPU resets.The pilgrimage no longer ends at the terminus — it ends where documents end: in appendices. Past the 720vh spacer the page becomes an ordinary scrolling document again. Scroll progress for the camera is now measured against the spacer alone (p = scrollY / (spacer.height − innerHeight)), and a second value, over, ramps 0→1 across half a viewport past the spacer's end — fading out the verse layer, the movement counter and the coordinates as the notes slide up over the fixed scene.
scrollIntoView — the site's only concession to being a website.Three late touches deepen the idle life: the stars drift (a slow rotation of the shell, one revolution every ~20 minutes), the ember breathes on a ~30-second tide even when nothing happens, and the monolith carries one near-subliminal microinteraction — the fresnel rim leans toward the cursor (rim *= 1.0 + 0.9 · uLean · N.x, where uLean is the smoothed horizontal pointer offset). You cannot point at it and say what changed. That is the point.
height: 720vh). Scroll progress = scrollY / (scrollHeight − innerHeight), smoothed with an exponential lerp.