[ Parable build guide ]
How Fathom was built
A public aquarium, rendered without a single photograph. The memorable moments are a raw-WebGL caustics shader rippling biolum light behind the hero, and a scroll-driven depth-column navigator that dives you from sunlit kelp to the crushing abyss — the light dimming and the pressure climbing on a live gauge as you go.
The idea
An aquarium's whole pitch is immersion — you walk downward, out of the daylight, into places that shouldn't be survivable. A page of stock reef photos can't carry that. So the site does two things a photo can't: it makes the water move (caustics), and it makes you descend (the depth column). The voice stays warm and plain-spoken — wonder up front, stewardship right behind it — because that's how good science communicators actually talk.
The stack
Deliberately plain. One index.html, one styles.css, one
main.js. No framework, no bundler, no runtime scripts — even the caustics are
hand-rolled WebGL, so there's nothing to build and it ships straight to GitHub Pages. The one
external request is the webfonts, pulled from the Google Fonts CDN; block it and the type
degrades cleanly to the Georgia / system-ui / ui-monospace fallback stacks. Type is Spectral (a
literary serif with a marine, cartographic feel), Inter for body, and Space Mono for every
instrument reading — depth, pressure, temperature, ticket totals.
Signature one — the caustics shader
Caustics are the net of bright, wobbling light the surface casts onto everything below. We fake
them with a classic trick: run a point through a short feedback loop of sines and
cosines, take the reciprocal length each pass, and the accumulation snaps into thin, animated
light filaments. Raise it to a high power and only the brightest lines survive — the caustic net.
A vertical smoothstep fades the light out toward the deep, and the whole thing is
tinted biolum-cyan over abyss.
// inside the fragment shader — the caustic feedback loop
vec2 q = mod(p * TAU * 2.15, TAU) - 250.0;
vec2 i = q; float c = 1.0; float inten = 0.0045;
for (int n = 0; n < 5; n++) {
float tt = t * (1.0 - (3.5 / float(n + 1)));
i = q + vec2(cos(tt - i.x) + sin(tt + i.y), sin(tt - i.y) + cos(tt + i.x));
c += 1.0 / length(vec2(q.x / (sin(i.x + tt) / inten),
q.y / (cos(i.y + tt) / inten)));
}
c = 1.17 - pow(c / 5.0, 1.4);
float glow = pow(abs(c), 7.0); // keep only the bright filaments
It draws on a single fullscreen triangle, caps device-pixel-ratio at 1.5, runs at ~30fps, and a
u_dark uniform brightens the water in light mode. If the WebGL context fails, a CSS
gradient stands in and you'd never know.
Signature two — the depth column
The zones are just stacked bands that get darker as they go down. The intelligence is a sticky
gauge: on scroll it finds which zone the viewport's midline is crossing, reads
that zone's real depth range off data-top/data-bot, and interpolates an
exact depth — then derives pressure, temperature and remaining sunlight from physics-ish curves.
const frac = clamp((mid - r.top) / r.height, 0, 1);
const depth = Math.round(top + (bot - top) * frac);
const pressure = 1 + depth / 10; // ~1 atm per 10 m
const temp = Math.max(2, 19 - depth * 0.014);
const light = 100 * Math.exp(-depth / 150); // gone by ~1000 m
That's why the "Sunlight" readout hits 0% right as you reach the twilight band — the numbers are tied to the same depth the band is illustrating, not faked on a timer.
Details that matter
- The deep stays dark in both themes. The depths section carries its own dark token scope, so even in light mode the descent reads as a descent — you're diving into a portal.
- Bioluminescence is the only colour down deep. Creature silhouettes dim zone by
zone, but their light organs (
.glow,.lure) stay biolum-cyan, so the anglerfish's lure is the brightest thing on the screen. - It pauses when unseen. An
IntersectionObserverstops the shader loop the moment the hero scrolls away. - Reduced motion draws one frame. Under
prefers-reduced-motionthe caustics render a single lit still, the gauge stops animating, and every reveal resolves instantly. - Content is never hidden without JS. The hero rise and scroll reveals are gated
behind a
.jsclass set synchronously in<head>— kill JavaScript and the page is fully legible. - All art is inline. Every creature, exhibit and icon is hand-drawn SVG or a canvas — not one external image file.
The ticket checkout is a demo — it computes a live total, validates and confirms in-place, but charges nothing and books nothing. Wire it to a real checkout (Stripe, a timed-entry ticketing API) before taking payments.
Ship it on GitHub Pages
Nothing to build. Push the folder and point Pages at the root.
gh repo create bswxyz/fathom --public --source . --push
gh api --method POST /repos/bswxyz/fathom/pages -f 'source[branch]=main' -f 'source[path]=/'
Relative paths and a .nojekyll file mean it serves from the project subpath without
a single config change. That's the whole point of the static build.