Library / Teardown

Scroll is
depth.

A page for a camera housing rated to 300 metres, where scrolling takes you down. One number — depth — drives everything on it: which film is showing, how much red is left, the load on the glass, the daylight. Here is the mapping, the optics of seawater, what it costs, and what we would do differently next time.

6.8 KBOf physics
0.34 µsPer depth
6Published models

NYCTOR is a camera housing that does not exist, rated to 300 metres. Its page has one idea: the scroll bar is a depth gauge. Move down and you go down, and everything on the screen is recomputed from how deep you are — not faded on a timeline, computed from published models of seawater.

The films were generated; the numbers were not. Every figure the page prints can be checked with a calculator and a paper from the reference list.

Go down yourself

Six painted swatches, lit by the sunlight that is left at each depth. The top strip of each is its colour at the surface; below it is what reaches it underwater. The slider is the page's own scroll mapping, so it starts slow and gets faster.

The same modelSunlit zone
Red
Orange
Yellow
Green
Cyan
Blue
10 m
8.2 %Red left
58 %Green left
89 %Blue left
2.0 barPressure
0.07 tOn the port
58k lxDaylight

The exposure is held, the way a camera's would be, so what changes is the hue rather than the brightness. Red goes first. Cyan — the site's own accent — goes blue on the way down.

1 · The scroll is a logarithm

Almost everything interesting happens near the surface: red is gone in the first ten metres, green in the first fifty. A linear scroll would give those ten metres 3 % of the page. So the section's scroll is mapped to depth exponentially.

scroll → depth
const MAX_DEPTH = 300;

function depthNow() {                             // depth belongs to one section, not the document
  const sec = document.getElementById('depth');
  const span = sec.offsetHeight - innerHeight * 0.7;
  const seen = scrollY - sec.offsetTop + innerHeight * 0.45;
  const t = Math.min(1, Math.max(0, seen / Math.max(1, span)));
  return Math.pow(MAX_DEPTH, t) - 1 + t;          // 0 m at t = 0, exactly 300 m at t = 1
}

With that curve the first 10 m take 41 % of the scroll, the first 42 m take 66 %, and the last 200 m — where only blue is left and nothing changes quickly — take the final 19 %. The − 1 + t is there so the ends land exactly: 300⁰ − 1 is 0, and 300¹ − 1 + 1 is 300.

2 · Depth is a path through water

Sunlight reaching something at depth d has travelled d metres of water. Beer–Lambert gives the fraction of each wavelength that survives, from the water's absorption and scattering.

depth → what survives
// pure-water absorption, m⁻¹ — Pope & Fry (1997), every 25 nm across the visible band
const ABSORPTION = [[400, 0.00663], [425, 0.00619], [450, 0.00922], /* … */ [700, 0.62400]];

const scattering = (nm) => 0.0029 * (400 / nm) ** 4.32;      // Morel (1974)
const attenuation = (nm) => absorption(nm) + scattering(nm); // c = a + b

function transmittance(nm, metres) {                         // Beer–Lambert: I / I₀ = e^(−c·L)
  return Math.exp(-attenuation(nm) * metres);
}

The table is the measured data, linearly interpolated between rows. Nothing here was tuned: the colour of the page at 40 m is whatever those two papers say it is.

3 · Three wavelengths stand for red, green and blue

A screen has three channels, so the model is sampled at three wavelengths near the peaks of the sRGB primaries, and each channel is scaled by what survives.

depth → RGB
const PRIMARIES = { r: 610, g: 545, b: 465 };             // nm

function colourAtDepth(depth) {
  return {
    r: transmittance(PRIMARIES.r, depth),
    g: transmittance(PRIMARIES.g, depth),
    b: transmittance(PRIMARIES.b, depth),
  };
}

const extinctionDepth = (nm, fraction = 0.1) => -Math.log(fraction) / attenuation(nm);

The attenuation is 0.249 per metre for red, 0.054 for green and 0.012 for blue — blue is dimmed twenty-one times more slowly than red. Ninety per cent of red is gone by 9.2 m, orange by 13.7 m, yellow by 29 m, green by 42.4 m. Blue holds to 194 m, which is why everything below that looks the same colour.

4 · The films cross-fade on the same scale

Four films were made, one at each of 8, 40, 120 and 300 m, each an eight-second take on a locked camera that ends where it began. Between two of them the page cross-fades on log depth, and each film plays through once across its own band, so scrolling either way never jumps.

depth → which film
const PLATES = [{ depth: 8 }, { depth: 40 }, { depth: 120 }, { depth: 300 }];

function mix(depth) {                                         // which films, and how strongly
  const ln = (x) => Math.log(Math.max(1, x));
  for (let i = 1; i < PLATES.length; i++) {
    if (depth <= PLATES[i].depth) {
      const a = PLATES[i - 1].depth, b = PLATES[i].depth;
      const t = (ln(depth) - ln(a)) / (ln(b) - ln(a));
      return [{ i: i - 1, w: 1 - t }, { i, w: t }];
    }
  }
}

At 20 m that is 43 % of the 8 m film and 57 % of the 40 m one; at 60 m it is 63 % of the 40 m film. The light falls off exponentially, so a cross-fade on a log scale tracks it; a linear one would hold the bright film too long.

5 · Everything else is a function of depth

The same number feeds five more models, and every card on the page prints their output.

depth → pressure, load, air, light
const pressure = (d) => 101325 + 1025 * 9.80665 * d;                 // Pa, seawater
const portTonnes = (d) => (pressure(d) - 101325) * Math.PI * 0.045 ** 2 / (1000 * 9.80665);
const trappedFraction = (d) => 101325 / pressure(d);                 // Boyle: pV = constant

function reach(lumens = 3200, floor = 0.5) {    // the lamp: inverse square × Beer–Lambert
  const I = lumens / (4 * Math.PI), c = attenuation(545);
  const E = (r) => I / (r * r) * Math.exp(-c * r);
  let lo = 0.05, hi = 200;
  for (let i = 0; i < 80; i++) { const mid = (lo + hi) / 2; if (E(mid) > floor) lo = mid; else hi = mid; }
  return (lo + hi) / 2;                          // metres at which the light drops to 0.5 lux
}

At 300 m that is 31.2 bar, 1.96 tonnes on a 90 mm sapphire port, the air sealed at the dock squeezed to 3.25 % of its volume, and 0.0085 lux of daylight — against 100 000 at the surface. The lamps reach 15.0 m. A sonar ping to the surface and back takes 401 ms (Mackenzie's sound speed; Thorp's attenuation says how far it carries).

What it costs

PartSizeNote
The physics (js/depth.js)6.8 KBsix models, no dependencies
The descent (js/descent.js)5.4 KBscroll mapping, film mix, loading
Every reading at one depth0.34 µspressure, load, colour, daylight, air, ping; M1 Pro
The films48 MBten eight-second takes, loaded whole so they can be scrubbed

The lamp's reach is the only reading that iterates — eighty halvings, 0.6 µs — and it does not even depend on depth. As on every page like this, the physics is a rounding error next to the video.

What we would do differently

Give type its own rule from the start. An early version drained the whole interface with the water, text included, by multiplying every colour by what survives. By the deep end the type had turned a violet that could not be read. The rule that came out of it: decide first which colours carry information and which carry atmosphere. The accent can take the physics at full strength and die into blue; the type only cools, between two whites that stay readable at every depth.

information and atmosphere
const drain = 1 - t.g;                               // 0 at the surface, 1 once green is spent
const accent = scale(BRAND, t);                      // the accent takes the loss neat
const ink = lerp([238, 246, 248], [196, 218, 245], drain);   // the type only cools

Lock the camera before asking for anything else. Every film take came back with a slow push-in — about 1.3× over eight seconds. Scrubbed by scroll, that reads as the camera lurching whenever the reader moves, and a take that does not end where it began cannot be scrubbed back without a jump. What held it was giving the generator the same still as the first and the last frame, and saying so in the prompt. The drift fell to five pixels or less at 480 wide, the exposure came back to within 1 %, and a script now checks every take for exactly that before it goes on a page.

Say which water it is. The model is pure seawater — the clearest water there is. Real water carries plankton and sediment and loses its light far sooner, so every depth on the page is the best case. The water type belongs among the inputs, not in a footnote.

Sources

Absorption: Pope & Fry, Absorption spectrum (380–700 nm) of pure water, Applied Optics 36 (1997). Scattering: Morel, Optical properties of pure water and pure sea water (1974). Sound speed: Mackenzie, Nine-term equation for sound speed in the oceans, JASA 70 (1981). Attenuation: Thorp (1967). Hydrostatics and Boyle's law as in any physics text. NYCTOR is fictional.