A scroll-driven landing page for a cine prime lens, where the lens is not a model file. There is no GLB, no OBJ, nothing exported from Blender. The object you scroll through is generated at runtime from an 18-row optical prescription, the same kind of table a lens designer actually writes.
Vanilla JavaScript, three.js and Lenis. No build step, no bundler, no
node_modules. Clone it and open it.
Most WebGL product pages load a model an artist made. This one derives the object from physics, and that changes what the page can do.
Every glass surface has a real radius of curvature. The material's index of
refraction is the real index of the real glass type. The light path in chapter
04 is an actual meridional ray trace, refracted by Snell's law at every sphere
it meets, not an illustration of one. The design is validated: a 10 degree
field lands at 7.04mm, which is exactly 40 * tan(10).
Three consequences fall out of that, and they are the reason the page works:
It comes apart properly. The exploded view is not an animation of pieces flying away. There genuinely are ten separate elements in eight groups, because the table says so, so they can be separated and inspected individually.
Nothing can contradict anything. Every number printed on the page is computed from the same table that produced the geometry. The angle of view in the focal-length panel is derived from the image circle, which is why it reports exactly the 31.0 degrees the spec strip prints. They cannot disagree, because they are the same calculation.
Change one number and the whole thing rebuilds. Edit a radius in
src/optics.js and the glass relathes, the barrel refits around it, the ray
trace re-solves, and the copy updates.
It uses ES modules, so it needs a server rather than file://:
npx serve .
# or
python3 -m http.server 8000Then open http://localhost:8000.
If you want a single file you can double-click, see Standalone builds below.
Four layers, stacking in one direction. Nothing points back up.
main.js -> lens3d.js -> optics.js -> conductor.js
| file | lines | what it does |
|---|---|---|
src/conductor.js |
140 | Turns scrollY into one number: position along the route. Knows nothing about lenses. |
src/optics.js |
618 | The physics. The prescription, the ray tracer, the solvers. No three.js, no rendering. Runs in Node. |
src/lens3d.js |
1090 | Numbers into geometry. Lathes the glass, fits the barrel, builds the iris, rays, blueprint and lighting. |
src/main.js |
1649 | The choreography. Camera ledger, phase windows, render loop, all DOM wiring. |
Absolute, never incremental. Every scroll-driven value is a pure function of scroll position. Nothing accumulates, nothing is "undone". Scroll back up and the lens reassembles bit for bit. This is tested on every change by sampling 61 positions going down, then the same 61 going up, and requiring the difference to be exactly zero.
A chapter states what it wants to see, never where the camera is.
{ id: 'explode', dir: [-1, 0.09, 0], fov: 26, pad: 1.14,
frame: 'glass', shift: 0, lift: -0.02, ease: 'glide', hold: 0.58 }Read that as: look from this direction, at this focal length, framing the glass, with this much air around it, pushed this far across the frame. The camera then solves its own distance every frame by fitting the object's real bounding box to the real window. That is why it reframes correctly when the lens explodes to three times its length, and why it works at any window size with no tuning.
| what | where |
|---|---|
| The lens itself | RAW_SURFACES in src/optics.js. Everything downstream follows. |
| Camera choreography | The CHAPTERS ledger in src/main.js |
| When things happen | phases() in src/main.js, overlapping smoothstep windows |
| Palette | PLATE_BLACK and PLATE_PAPER in src/main.js |
| Type | The --sans, --wide, --semi, --tech, --read tokens in styles.css |
| Sample photographs | frames/, see the note in that folder |
NOTES.md is the long version: an engineering journal of every non-obvious
decision, and more usefully every trap, with the measurement that caught it.
tools/build-standalone.py folds the whole site into one HTML file you can
double-click.
python3 tools/build-standalone.py # 2.3 MB
python3 tools/build-standalone.py --strip # 1.5 MB, comments removedThe interesting constraint is that file:// refuses to fetch ES modules, so the
build cannot simply concatenate the sources. It rewrites every module into an
IIFE and turns imports into registry lookups, producing one classic script.
tools/README.md explains the preconditions that makes safe, and the two regex
traps that cost an hour.
Between the first working version of this page and the shipped one,
styles.css went from 16 KB to 59 KB and src/main.js from 26 KB to 78 KB.
Over the same stretch src/optics.js and src/conductor.js did not change by a
single byte.
The physics was right in the first pass. Everything after that was presentation.
The whole page can be rebuilt from scratch, in order, without copying anything from this repo.
- PROMPTS.md — ten prompts to paste into Claude Code, one after another, in an empty folder. Each one ends with a check so you know whether to carry on. They are long on purpose: the tuned numbers in them are the ones that cannot be guessed.
- TEACHING-BRIEF.md — the same build described as nine stages rather than as prompts, with the traps marked before you walk into them.
You will not get a pixel-for-pixel copy of this page, and nobody will. Two people running the same prompts get slightly different results, because you are describing something in words rather than running a build script. What you will get is every part of it, working.
- The prices are invented. £14,500, £39,500 and the 14 week lead time in the closing section are placeholder figures. NODAL is not a real company.
- The social links are
#. Deliberately, so they cannot point at a real stranger's account. - The three photographs in
frames/are also served from a CDN for the standalone build. Seeframes/README.md.
- three.js r185, MIT
- Lenis 1.3.26, MIT
- Martian Mono, OFL
Both libraries are vendored in vendor/ rather than installed, which is what
lets the project run with no build step. Their notices are in
THIRD-PARTY.md.
The photographs in frames/ are not cleared for reuse. Replace them before
you publish a fork.
MIT. See LICENSE.