Library / Tutorial

Scroll-to-build sneaker,
step by step.

A product page where scroll builds a sneaker from 953 real-size bricks, layer by layer, with a live counter, then turns it and offers Add to cart. One scroll progress drives all of it. Six stages, each a live page you can run.

6Stages
953Bricks
1Motor

This is the whole build of the scroll-to-build sneaker, in the order it was made. A product page for a made-up shoe, the AURORA BRICKRUNNER 01, where scrolling builds the shoe out of 953 real-size plastic bricks, one layer at a time, with a live counter. At the end the shoe turns all the way round and Add to cart appears. It is plain HTML, CSS and JavaScript plus three.js, with no framework and no build step. Every stage below is a working page: look at the still, or run it right here.

How to read this

Each code block is copied from the stage it belongs to, unchanged. A … marks lines left out for space. The last stage is byte for byte the finished page, so the download at the end is exactly what you see on the demo.

1 · The page and its parts list

Start without any 3D. The page is a fixed nav, one very tall section and, inside it, a sticky stage the height of the screen. While you scroll through the section, the stage stays put: that is the track scroll will drive in stage 3. Until then a still picture of the finished shoe stands in.

index.html — the head
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#F3F9FA">
<title>BRICKRUNNER 01 · AURORA — step 1</title>
<meta name="description" content="Scroll to build the BRICKRUNNER 01 from real-size plastic bricks, one layer at a time. A fictional sneaker by a fictional brand.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;500;600;700&family=Bebas+Neue&display=swap">
<link rel="stylesheet" href="css/page.css">
<link rel="modulepreload" href="js/shoe.js">
index.html — the build section
<section class="build" data-build aria-labelledby="title">
  <div class="stage" data-stage>
    <img class="still" data-still src="assets/brickrunner_built.webp" alt="" width="1600" height="1000" decoding="async">

    <div class="intro">
      <p class="kicker"><span class="k-desk">Fictional brand</span><span class="k-phone">AURORA</span></p>
      <h1 id="title" class="title">Brickrunner 01</h1>
    </div>

  </div>
</section>
page.css — tokens
:root {
  color-scheme: light;
  --page: #F3F9FA; --page-2: #E8F0F2; --card: #FFFFFF;
  --ink: #17181B; --ink-2: #3A3D42; --dim: #8D9094; --line: #D9E2E5;
  --teal: #2EC6CF; --teal-d: #0C838B; --magenta: #E8458E; --yellow: #F4C83A;
  --display: 'Bebas Neue', 'Barlow Condensed', Impact, 'Arial Narrow', sans-serif;
  --cond: 'Barlow Condensed', 'Arial Narrow', 'Helvetica Neue', Arial, sans-serif;
  --gut: clamp(16px, 4.6vw, 112px);
  --focus: 0 0 0 3px #fff, 0 0 0 5.5px var(--teal-d);
}
page.css — one tall section, a sticky stage
.build { position: relative; height: 640vh; }
.stage {
  position: sticky; top: 0; height: 100vh; height: 100svh; overflow: hidden;
  background:
    radial-gradient(60% 55% at 52% 46%, #FFFFFF 0%, rgba(255, 255, 255, 0) 70%),
    linear-gradient(180deg, #F5FAFB 0%, #F1F7F8 62%, #E9F1F3 100%);
}

The page never follows the OS colour scheme: color-scheme: light and a light theme-color, with no dark-mode rules anywhere. The shoe is bright on purpose.

The shoe itself is data. shoe.js sculpts it as a height field over a sole outline: how wide the sole is from heel to toe, where the toe turns in, and how high the upper rises along its centre line.

shoe.js — the shape
// sole outline: half-width in studs (to the outer edge) along u = 0 heel … 1 toe
const SOLE = [[0, 2.7], [0.03, 4.3], [0.08, 5.0], [0.18, 5.3], [0.32, 5.05], [0.46, 4.85], [0.6, 5.6], [0.72, 6.05], [0.82, 5.9], [0.9, 5.15], [0.95, 4.1], [0.985, 2.7], [1, 1.3]];
const soleHalf = (u) => lerpTable(SOLE, u);
const centre = (u) => -0.55 * Math.max(0, Math.min(1, (u - 0.45) / 0.55)) ** 1.6;   // the toe turns in a little (medial = +z)
const upperHalf = (u) => Math.max(0.6, soleHalf(u) - (u > 0.9 ? 0.55 : 0.85));
// top line along the centre (absolute plates)
const TOP = [[0, 40], [0.05, 42], [0.14, 40.5], [0.24, 37], [0.34, 38.5], [0.41, 42.5], [0.46, 43.5], [0.52, 40], [0.59, 34], [0.66, 29], [0.74, 25.5], [0.82, 23], [0.9, 20.5], [0.95, 17.5], [0.985, 14], [1, 11.5]];

Those curves fill a grid of cells, one stud wide and one plate high (8 mm and 3.2 mm, the real sizes). Each cell gets a zone: outsole, midsole, upper, and later the toe cap, the collar, the stripe and the lining.

shoe.js — cells
const cells = new Map();                  // "x,y,z" → zone
const colTop = new Map();                 // "x,z" → top (exclusive) plate
const Z0 = -8, Z1 = 8;
for (let x = 0; x < LEN; x++) {
  const u = (x + 0.5) / LEN, c = centre(u);
  for (let z = Z0; z < Z1; z++) {
    const zc = z + 0.5, ds = Math.abs(zc - c);
    if (ds > soleHalf(u) - 0.3) continue;
    // sole
    for (let y = 0; y < SOLE_TOP; y++) cells.set(key(x, y, z), y < 2 ? 'outsole' : 'midsole');
    let top = SOLE_TOP;
    const uh = upperHalf(u);
    if (ds <= uh - 0.2) {
      const zn = (zc - c) / uh;
      top = topAt(u, zn);
      const open = inOpening(u, zn);
      for (let y = SOLE_TOP; y < top; y++) {
        if (open && y >= OPEN.floor) break;
        cells.set(key(x, y, z), 'upper');
      }
      if (open) top = Math.min(top, OPEN.floor);
    }
    colTop.set(x + ',' + z, top);
  }
}

Then the cells are solidified into real parts. Two plates of outsole, then courses three plates high: a column that fills a whole course becomes part of a brick, and a column whose top falls inside a course is finished in plates, so the surface steps down the way a real build does. Each course is packed greedily with real sizes (1×1 to 2×8), and every placement is chosen to bond the most separate groups below it.

shoe.js — a part takes its cells
function put(kind, w, l, h, zone, x, y, z, rot) {
  const p = { kind, args: kind === 'brick' ? [w, l, h] : [w, l], zone, x, y, z, rot, h };
  const i = parts.length;
  const [fw, fl] = rot % 2 ? [l, w] : [w, l];
  for (let a = 0; a < fw; a++) for (let b = 0; b < fl; b++) for (let c = 0; c < h; c++) {
    const k = key(x + a, y + c, z + b);
    if (owner.has(k)) ERR.push('overlap at ' + k);
    owner.set(k, i);
  }
  parts.push(p); return i;
}
shoe.js — course by course
// courses: two outsole plates, then 3-plate brick courses; a column whose top falls inside a course is finished in plates
const maxY = Math.max(...[...cells.keys()].map((k) => +k.split(',')[1])) + 1;
const COURSES = [[0, 1], [1, 1]];
for (let y = 2; y < maxY; y += 3) COURSES.push([y, 3]);
COURSES.forEach(([y0, h], ci) => {
  const alongZ = ci % 2 === 0;
  const full = new Map(), part = new Map();
  const add = (m, g, v) => { if (!m.has(g)) m.set(g, new Set()); m.get(g).add(v); };
  const cols = new Set();
  for (const k of cells.keys()) { const [x, y, z] = k.split(',').map(Number); if (y >= y0 && y < y0 + h) cols.add(x + ',' + z); }
  for (const c of cols) {
    const [x, z] = c.split(',').map(Number);
    const zs = []; for (let y = y0; y < y0 + h; y++) zs.push(cells.get(key(x, y, z)));
    if (h > 1 && zs.every((v) => v && v === zs[0])) add(full, zs[0], c);
    else zs.forEach((v, i) => { if (v) add(part, (y0 + i) + '|' + v, c); });
  }
  for (const [zone, cs] of full) pack(cs, LONG.has(zone) ? (zone === 'fill' ? BR_FILL : BR_LONG) : BR_SURF, alongZ, y0, 3, 'brick', zone);
  // partial columns: plate by plate, bottom up, so each plate rests on the one before
  const ys = [...new Set([...part.keys()].map((g) => +g.split('|')[0]))].sort((a, b) => a - b);
  for (const y of ys) for (const [g, cs] of part) {
    const [yy, zone] = g.split('|'); if (+yy !== y) continue;
    pack(cs, LONG.has(zone) ? PL_LONG : PL_SURF, (alongZ ? 1 : 0) ^ ((y - y0) % 2), y, 1, 'plate', zone);
  }
});

Colour is per brick, never per cell, so each brick reads as one piece. Fixed zones get fixed colours; the upper is a patchwork that never repeats a touching neighbour's colour.

shoe.js — colours
const FIXED = { outsole: 'black', midsole: 'white', bumper: 'teal', toecap: 'white', stripe: 'yellow', stripe2: 'teal', collar: 'magenta', heeltab: 'grey', tongue: 'white', lining: 'white', insole: 'grey' };
const MOSAIC = [['teal', 0.36], ['magenta', 0.32], ['yellow', 0.2], ['white', 0.12]];

A validator proves the list before the page uses it: no two parts share a cell, every part rests on studs (or names the cell it clips into, like the laces), and the whole shoe is one connected model. The build order is layer by layer, each layer swept heel to toe and back like a print head, laces last, and it counts any part that would be placed floating. The result for this shoe: 953 parts, 1 group, 0 errors, 0 floating.

shoe.js — joints
function joints() {
  const n = parts.length, adj = Array.from({ length: n }, () => new Set());
  const link = (a, b) => { if (a !== b) { adj[a].add(b); adj[b].add(a); } };
  for (let i = 0; i < n; i++) {
    const p = parts[i];
    if (!GRID(p)) { const o = owner.get(key(...p.at)); if (o === undefined) ERR.push(`#${i} ${p.kind} clips into an empty cell`); else link(i, o); continue; }
    const [fw, fl] = p.rot % 2 ? [p.args[1], p.args[0]] : [p.args[0], p.args[1]];
    for (let a = 0; a < fw; a++) for (let b = 0; b < fl; b++) {
      const above = owner.get(key(p.x + a, p.y + p.h, p.z + b));
      if (above !== undefined && parts[above].y === p.y + p.h) link(i, above);
    }
  }
  return adj;
}
shoe.js — the build order
const order = parts.map((p, i) => i).filter((i) => GRID(parts[i]));
const xc = (p) => p.x + (p.rot % 2 ? p.args[1] : p.args[0]) / 2;
const zcf = (p) => p.z + (p.rot % 2 ? p.args[0] : p.args[1]) / 2;
order.sort((a, b) => {
  const A = parts[a], B = parts[b];
  if (A.y !== B.y) return A.y - B.y;
  const dir = (A.y % 2) ? -1 : 1;
  const ka = Math.round(xc(A) * 2) * dir, kb = Math.round(xc(B) * 2) * dir;
  return ka - kb || zcf(A) - zcf(B);
});
const seq = order.concat(parts.map((p, i) => i).filter((i) => !GRID(parts[i])));
shoe.js — the count
export const PARTS = seq.map((i) => parts[i]);
export const COUNT = PARTS.length;

The page prints that count everywhere it needs a number, and counts the inventory from the same list, so the number on screen is the number of parts in the model, never a figure typed by hand.

page.js — every total
$$('[data-total]').forEach((e) => { e.textContent = fmt(N); });
$$('[data-layers]').forEach((e) => { e.textContent = LAYER_COUNT; });
page.js — the inventory
const byKind = {};
for (const p of PARTS) {
  const name = p.kind === 'brick' ? `Brick ${p.args[0]}×${p.args[1]}` : p.kind === 'plate' ? `Plate ${p.args[0]}×${p.args[1]}` : p.kind === 'lace' ? 'Flex tube' : p.kind === 'loop' ? 'Lace loop' : 'Lace end';
  const o = byKind[name] || (byKind[name] = { n: 0, cols: new Set(), kind: p.kind, args: p.args });
  o.n++; o.cols.add(p.colour);
}

On a phone the copy moves to the bottom, the nav drops its links, and the section is a little shorter.

page.css — phone
@media (max-width: 720px) {
  .nav { height: 64px; gap: 12px; }
  .brand, .links { display: none; }
  .build { height: 560vh; }
  .intro { left: var(--gut); right: var(--gut); top: auto; bottom: calc(44px + env(safe-area-inset-bottom)); width: auto; }
  .k-desk { display: none; }
  .k-phone { display: inline; }
  .intro .kicker { font-size: 15px; letter-spacing: .04em; margin-bottom: 2px; }
  .title { font-size: clamp(46px, 14vw, 62px); }
  …
}
Stage 1 · the pageOpen ↗
The AURORA product page: the nav, the title BRICKRUNNER 01 on the left and a still picture of the finished patchwork sneaker in teal, magenta, yellow and white bricks on a black and white sole, on a cool white studio background.

2 · The 3D studio

three.js r186 is vendored next to the page and loaded through an import map, so there is no CDN and no bundler.

index.html — the import map
<script type="importmap">{"imports":{"three":"./vendor/three/build/three.module.js"}}</script>

The renderer draws on a transparent canvas over the CSS studio gradient, with neutral tone mapping and a room environment for soft reflections. One warm key light casts the only shadow, onto an invisible floor that only catches it; a rim and a fill light shape the edges.

stage.js — the renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: false });
renderer.setClearColor(0x000000, 0);
renderer.toneMapping = THREE.NeutralToneMapping; renderer.toneMappingExposure = 1.0;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.shadowMap.autoUpdate = false;

const scene = new THREE.Scene();
const pm = new THREE.PMREMGenerator(renderer);
scene.environment = pm.fromScene(new RoomEnvironment(), 0.04).texture; scene.environmentIntensity = 0.6;
pm.dispose();
stage.js — the studio
// key light: high, front-left of the camera; its soft shadow falls behind and under the shoe
const key = new THREE.DirectionalLight(0xfffaf2, 2.7);
key.position.set(300, 640, -360); key.castShadow = true;
key.shadow.mapSize.set(1536, 1536);
Object.assign(key.shadow.camera, { left: -420, right: 420, top: 420, bottom: -420, near: 100, far: 2200 });
key.shadow.bias = -0.0006; key.shadow.normalBias = 0.4; key.shadow.radius = 6;
scene.add(key); scene.add(key.target);
const rim = new THREE.DirectionalLight(0xdff6ff, 0.9); rim.position.set(-420, 260, 420); scene.add(rim);
const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-500, 120, -300); scene.add(fill);
stage.js — the floor
// ground: shadow catcher + a soft contact shadow that darkens as the sole goes down
const ground = new THREE.Mesh(new THREE.PlaneGeometry(20000, 20000), new THREE.ShadowMaterial({ opacity: 0.13, color: 0x1c2a38 }));
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);

The bricks come from a parts library with real dimensions: studs, tubes underneath, bevelled edges and a clear-coat plastic material with a faint grain.

bricks.js — a brick and a plate
export function brick(w, l, plates = 3) { const s = shell(w, l, plates * PLATE); return finish(s.body, s.caps); }
export function plate(w, l) { return brick(w, l, 1); }
bricks.js — the plastic
export function materials() {
  const body = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: 0.24, metalness: 0, clearcoat: 0.55, clearcoatRoughness: 0.18, sheen: 0.15, sheenRoughness: 0.5 });
  const face = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: 0.34, metalness: 0, clearcoat: 0.35, clearcoatRoughness: 0.25, normalMap: studNormalMap(), normalScale: new THREE.Vector2(0.55, 0.55) });
  const rubber = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: 0.62, metalness: 0, sheen: 0.4, sheenRoughness: 0.6 });
  const grain = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: 0.62, metalness: 0, normalMap: grainNormalMap(), normalScale: new THREE.Vector2(0.35, 0.35) });
  const printed = rubber.clone(); printed.userData.keepColor = true;
  return { brick: [body, face, grain], rubber: [rubber, printed, rubber] };
}

953 meshes would be 953 draw calls. Instead, every shape gets one InstancedMesh and each part is an instance of it with its own matrix and colour, with a ±2.5 % lightness wobble, because real plastic varies a little from part to part. In this stage every part goes straight into its seat.

stage.js — one instanced mesh per shape
const byShape = new Map(), laceMeshes = {};
PARTS.forEach((p, i) => { const k = GRID(p) ? p.kind + ':' + p.args.join('x') : 'lace#' + i; if (!byShape.has(k)) byShape.set(k, []); byShape.get(k).push(i); });
const N = COUNT;
const rest = new Array(N), cen = new Array(N), slot = new Array(N);
const meshes = [];
const col = new THREE.Color();
for (const [k, idx] of byShape) {
  const p0 = PARTS[idx[0]];
  const geo = GRID(p0) ? SHAPES[p0.kind](...p0.args) : laceGeometry(p0);
  // each lace is its own mesh: before its turn it waits in place as a pale ghost, then drops in solid
  const mesh = new THREE.InstancedMesh(geo, GRID(p0) ? mats : [Object.assign(laceMat[0].clone(), { transparent: true }), mats[1], mats[2]], idx.length);
  if (!GRID(p0)) { mesh.userData.lace = true; laceMeshes[idx[0]] = mesh; }
  mesh.castShadow = true; mesh.receiveShadow = false; mesh.frustumCulled = false;   // only the floor samples the shadow map (bricks are lit from the camera side; self-shadow was invisible and cost the most per pixel)
  idx.forEach((pi, j) => {
    const p = PARTS[pi], m = new THREE.Matrix4();
    if (GRID(p)) {
      const [fw, fl] = p.rot % 2 ? [p.args[1], p.args[0]] : [p.args[0], p.args[1]];
      m.compose(new THREE.Vector3((p.x + fw / 2) * P, p.y * PL, (p.z + fl / 2) * P), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), p.rot * Math.PI / 2), new THREE.Vector3(1, 1, 1));
      cen[pi] = new THREE.Vector3((p.x + fw / 2) * P, (p.y + p.h / 2) * PL, (p.z + fl / 2) * P);
    } else {
      geo.computeBoundingBox(); cen[pi] = geo.boundingBox.getCenter(new THREE.Vector3());
    }
    rest[pi] = m; slot[pi] = [mesh, j];
    mesh.setMatrixAt(j, m);                                   // stage 2: every part in its seat
    // real ABS varies a little from part to part: ±2.5 % lightness, so a wall of one colour still reads as many parts
    mesh.setColorAt(j, col.set(B.COLOURS[p.colour] ?? 0xff00ff).offsetHSL(0, 0, (rnd() - 0.5) * 0.05));
  });
  mesh.userData.dirty = true;
  inner.add(mesh); meshes.push(mesh);
}

The camera is fitted, not placed by hand: it finds the distance at which the shoe fills a given share of the width, then shifts the projection so the shoe's centre lands where the layout wants it. The page says where that is for each screen.

stage.js — fit the shoe
// fit: find the distance at which the shoe's projected width is `view.width` of the viewport, then shift the
// projection so the shoe's centre lands on (cx, cy)
function fit() {
  camera.clearViewOffset(); camera.aspect = view.w / view.h; camera.updateProjectionMatrix();
  let d = 900;
  for (let k = 0; k < 4; k++) {
    placeCamera(view.yaw, view.pitch, d); camera.updateMatrixWorld();
    let x0 = 1e9, x1 = -1e9;
    for (const c of corners) { const q = c.clone().project(camera); x0 = Math.min(x0, q.x); x1 = Math.max(x1, q.x); }
    d *= ((x1 - x0) / 2) / view.width;
  }
  view.dist = d;
}
stage.js — put it in place
function frameShift() {
  // where does the shoe's centre (mid sole-to-collar) project without an offset?
  camera.clearViewOffset(); camera.updateMatrixWorld();
  const q = new THREE.Vector3(0, 40, 0).project(camera);
  const px = (q.x * 0.5 + 0.5) * view.w, py = (-q.y * 0.5 + 0.5) * view.h;
  shift = [px - (view.cx + (view.cxOff || 0)) * view.w, py - view.cy * view.h];
  camera.setViewOffset(view.w, view.h, shift[0], shift[1], view.w, view.h);
}
page.js — the layout
function layout() {
  const w = innerWidth, h = innerHeight;
  if (w <= 720) return { cx: 0.47, cy: 0.45, width: 0.86, yaw: 0.85, pitch: 0.24, zoom0: 1.1 };
  if (h <= 500) return { cx: 0.56, cy: 0.62, width: 0.36, yaw: 0.85, pitch: 0.2, zoom0: 1.5 };
  if (w <= 1100) return { cx: 0.5, cy: 0.62, width: 0.6, yaw: 0.85, pitch: 0.2, zoom0: 1.4 };
  return { cx: 0.53, cy: 0.64, width: 0.5, yaw: 0.85, pitch: 0.2, zoom0: 1.3 };
}

The 3D has a pixel budget of about 2.8 million pixels. A 1440 × 900 screen renders at a pixel ratio of about 1.47 and a phone at 2. The text is HTML over the canvas, so it stays sharp at any ratio.

stage.js — the pixel budget
let dpr = Math.min(2, window.devicePixelRatio || 1);
// pixel budget: never more than ~2.8 MP of 3D (the DV49 budget: 1440×900 renders at 1.39, a phone at 2); text is DOM, so it stays sharp
const BUDGET = 2.8e6;
function resize(w, h, layout) {
  Object.assign(view, layout, { w, h });
  dpr = Math.min(dpr, Math.max(1, Math.sqrt(BUDGET / (w * h))));
  renderer.setPixelRatio(dpr); renderer.setSize(w, h, false);
  fit();
}
Stage 2 · the 3D studioOpen ↗
The same page with the sneaker now rendered live in 3D: 953 glossy bricks with studs, a soft shadow under the black outsole, yellow tube laces over a white tongue.

3 · Scroll is the motor

Nothing on this page plays by itself. One number, the scroll progress through the tall section from 0 to 1, decides everything: how many parts are placed, which are in the air, the camera, the turn at the end. A short hold at the top, the build until 0.79, a pause, a full turn by 0.95, and done.

page.js — the timeline
const T = { hold: 0.035, built: 0.79, turn0: 0.815, turn1: 0.95, done: 0.955 };
// where each layer starts in the build order (laces are the last layer)
const layerStart = []; PARTS.forEach((p, i) => { if (layerStart[p.layer] === undefined) layerStart[p.layer] = i; });
const layerOf = (placedOrFlying) => { let l = 0; for (let k = 0; k < layerStart.length; k++) if (placedOrFlying >= layerStart[k]) l = k; return l; };
const LAYER_LABEL = (l) => (l >= LAYERS.length ? 'Laces' : layerName(LAYERS[l]));
page.js — scroll → progress
let metrics = { top: 0, span: 1 };
function measure() { const r = build.getBoundingClientRect(); metrics = { top: r.top + scrollY, span: Math.max(1, build.offsetHeight - innerHeight) }; }
const progressAt = (y) => clamp((y - metrics.top) / metrics.span);
const scrollFor = (p) => metrics.top + p * metrics.span;

Before it is placed, every part waits on a tilted ring around the shoe, like Saturn's: low in front, high behind. Each one waits at roughly the angle of its own seat, so its flight is short, and it gets a small random tumble.

stage.js — the ring
const sc = new THREE.Vector3(CENTRE[0], 58, CENTRE[2]);
const FRONT = -0.72;                                     // the ring's angle nearest the camera (yaw 0.85)
for (let i = 0; i < N; i++) {
  const c = cen[i];
  if (reduced) { air[i] = new THREE.Vector3(0, 12, 0); qAir[i] = new THREE.Quaternion(); continue; }
  if (!GRID(PARTS[i])) {                                  // laces wait as a ladder right above the ghost's throat
    air[i] = new THREE.Vector3(0, 26, 0); rnd(); rnd();
    qAir[i] = new THREE.Quaternion();
    continue;
  }
  const th = Math.atan2((c.z - sc.z) / 60, (c.x - sc.x) / 150) + (rnd() - 0.5) * 0.7;
  const rr = (rnd() - 0.5) * 44, rx = 206 + rr, rz = 178 + rr * 0.8;
  const y = sc.y + 4 - 54 * Math.cos(th - FRONT) + (rnd() - 0.5) * 18;
  air[i] = new THREE.Vector3(sc.x + Math.cos(th) * rx - c.x, y - c.y, sc.z + Math.sin(th) * rz - c.z);
  const ax = new THREE.Vector3(rnd() - 0.5, rnd() - 0.5, rnd() - 0.5).normalize();
  qAir[i] = new THREE.Quaternion().setFromAxisAngle(ax, 0.3 + rnd() * 0.9);
}

Inside the ring stands a ghost of the finished shoe: every visible part as a pale box 0.6 mm inside the real one. A placed brick hides its own ghost by the depth test, so nothing has to be tracked per part. The first frame already reads as a sneaker made of bricks.

stage.js — the ghost
// the ghost: every visible part as an inset box (fill + edges). Each box sits 0.6 mm inside its real part, so a
// placed brick hides its own ghost by the depth test — nothing to track per part.
const ghost = new THREE.Group(); inner.add(ghost);
{
  const fill = [], cols = [], lines = [];
  const gc = new THREE.Color(), white = new THREE.Color(0xffffff);
  let cur = [1, 1, 1];
  const quad = (a, b2, c2, d) => { fill.push(...a, ...b2, ...c2, ...a, ...c2, ...d); for (let k = 0; k < 6; k++) cols.push(...cur); };
  for (let i = 0; i < N; i++) {
    const p = PARTS[i]; if (!GRID(p) || p.zone === 'fill') continue;
    const [fw, fl] = p.rot % 2 ? [p.args[1], p.args[0]] : [p.args[0], p.args[1]];
    const x0 = p.x * P + 0.7, x1 = (p.x + fw) * P - 0.7, y0 = p.y * PL + 0.6, y1 = (p.y + p.h) * PL - 0.6, z0 = p.z * P + 0.7, z1 = (p.z + fl) * P - 0.7;
    gc.set(B.COLOURS[p.colour] ?? 0xffffff).lerp(white, 0.38); cur = [gc.r, gc.g, gc.b];
    const v = [[x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1]];
    quad(v[4], v[7], v[6], v[5]); quad(v[0], v[1], v[2], v[3]); quad(v[0], v[4], v[5], v[1]); quad(v[2], v[6], v[7], v[3]); quad(v[1], v[5], v[6], v[2]); quad(v[3], v[7], v[4], v[0]);
    for (const [a2, b3] of [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]]) lines.push(...v[a2], ...v[b3]);
  }
  …
}

A part's flight is a number k from 0 to 1. The position eases out (a fast start, a soft landing), the tumble unwinds a little earlier, and in the last 8 % the part drops 1.7 mm onto the studs: the click you feel in a real build.

stage.js — one flight
function partMatrix(i, k) {
  if (k >= 1 || (k === 0 && !GRID(PARTS[i]))) return rest[i];
  // position eases out (fast start, soft landing); the tumble unwinds a little earlier; the last 8 % presses the
  // studs home (a 1.7 mm drop) — the click you feel in a real build
  const e = 1 - Math.pow(1 - k, 3), er = 1 - Math.pow(1 - Math.min(1, k * 1.15), 4);
  pos.copy(air[i]).multiplyScalar((1 - e) * (GRID(PARTS[i]) ? airScale : 1)); if (k > 0) pos.y += k > 0.9 ? 1.7 * (1 - (k - 0.9) / 0.1) : 1.7;
  Q.copy(qAir[i]).slerp(I, er);
  const c = cen[i];
  // M = T(c + pos) · R(Q) · T(-c) · rest
  M.makeRotationFromQuaternion(Q);
  M.setPosition(pos.x + c.x - (M.elements[0] * c.x + M.elements[4] * c.y + M.elements[8] * c.z), pos.y + c.y - (M.elements[1] * c.x + M.elements[5] * c.y + M.elements[9] * c.z), pos.z + c.z - (M.elements[2] * c.x + M.elements[6] * c.y + M.elements[10] * c.z));
  return T1.multiplyMatrices(M, rest[i]);
}

setBuild(b) turns one build value into every part's matrix. Part i is in the air while b is between i and i + 46, so about 46 parts fly at once. Only the parts whose state changed are written, and a waiting part's matrix is a cached copy plus the ring's offset.

stage.js — setBuild
function setBuild(b, ringScale = 1) {
  const ringMoved = Math.abs(ringScale - airScale) > 1e-4; if (ringMoved) airScale = ringScale;
  let placed = 0, flying = 0;
  for (let i = 0; i < N; i++) {
    const k = Math.min(1, Math.max(0, (b - i) / W));
    if (k >= 1) placed++; else if (k > 0) flying++;
    if (k === f[i] && !(k === 0 && ringMoved)) continue;
    f[i] = k;
    const [mesh, j] = slot[i];
    if (k === 0 && GRID(PARTS[i])) {
      // waiting on the ring: a cached matrix plus the ring's scaled offset (16 copies + 3 adds, no matrix maths)
      const a = mesh.instanceMatrix.array, o = j * 16, m0 = wait0[i];
      a.set(m0, o); a[o + 12] += air[i].x * airScale; a[o + 13] += air[i].y * airScale; a[o + 14] += air[i].z * airScale;
    } else mesh.setMatrixAt(j, partMatrix(i, k));
    mesh.userData.dirty = true;
    if (mesh.userData.lace) {
      const m = mesh.material[0];
      m.opacity = k === 0 ? 0.32 : 0.55 + 0.45 * k; m.depthWrite = k > 0; mesh.castShadow = k > 0;   // stays `transparent`: toggling it would recompile
    }
  }
  for (const m of meshes) if (m.userData.dirty) { m.instanceMatrix.needsUpdate = true; m.userData.dirty = false; }
  state.placed = placed; state.inFlight = flying; state.build = b;
  ghost.visible = placed < N; state.ghost = ghost.visible;
  …
}

The page maps the progress to that build value, to the turn (an ease-in-out over a full circle), to a slow quarter orbit of the camera while it builds, and to a zoom that closes in as the ring draws in.

page.js — the state at a progress
function stateFor(p) {
  const b = (N + W) * clamp((p - T.hold) / (T.built - T.hold));
  const tt = clamp((p - T.turn0) / (T.turn1 - T.turn0));
  const turn = reduced ? 0 : Math.PI * 2 * (tt < 0.5 ? 4 * tt ** 3 : 1 - (-2 * tt + 2) ** 3 / 2);
  const k = smooth(clamp(p / T.built));
  return {
    b, turn,
    orbit: reduced ? 0 : 0.2 * (1 - k),                         // a slow quarter-orbit while it builds
    zoom: reduced ? 1 : Z0 - (Z0 - 1) * Math.sqrt(k) + 0.3 * Math.sin(Math.PI * tt) - 0.03 * smooth(clamp((p - T.turn0) / (T.done - T.turn0))),
    lift: reduced ? 0 : 0.06 * (1 - k),
    ring: 1 - 0.5 * k,
    cxOff: reduced || innerWidth <= 720 ? 0 : 0.07 * Math.sin(Math.PI * tt) + 0.015 * (1 - k),
  };
}

Scrolling only sets a target. A damped follower moves the shown progress towards it in each animation frame and stops asking for frames when it arrives, so a scroll wheel's jumps become smooth, and the page is idle when you stop. The floor's shadow is refreshed every third frame in motion and always on the resting frame.

page.js — the follower
function tick(now) {
  raf = 0;
  const dt = Math.min(64, now - (view.last || now));
  view.last = now;
  const follow = STUDIO || reduced ? 1 : 1 - Math.exp(-dt / 85);
  view.shown += (view.target - view.shown) * follow;
  if (Math.abs(view.target - view.shown) < 1e-5) view.shown = view.target;
  const settled = view.shown === view.target;
  view.frame = (view.frame || 0) + 1;
  draw(view.shown, settled || view.frame % 3 === 0);
  if (!settled) want(); else view.last = 0;
}
page.js — scroll and resize
function onScroll() { view.target = progressAt(scrollY); want(); }
function onResize() {
  measure();
  const L = layout(); Z0 = L.zoom0;
  if (stage) stage.resize(innerWidth, innerHeight, L);
  view.target = progressAt(scrollY); view.shown = view.target; lastPlaced = -1; lastLayer = -1;
  draw(view.shown);
}
addEventListener('scroll', onScroll, { passive: true });

Each frame updates the counter, the layer line and the phone's bar and rail, and announces the layer in a polite live region, 0.7 s after it changes so a fast scroll does not flood a screen reader.

page.js — draw
function draw(p, shadow = true) {
  const s = stateFor(p);
  let placed;
  if (stage) {
    stage.setBuild(s.b, s.ring);
    stage.render({ turn: s.turn, orbit: s.orbit, zoom: s.zoom, lift: s.lift, cxOff: s.cxOff, shadow });
    placed = stage.state.placed;
  } else placed = N;
  // counter + phase + progress
  if (placed !== lastPlaced) {
    ui.count.textContent = fmt(placed); ui.short.textContent = `${fmt(placed)} / ${fmt(N)}`;
    const pct = placed / N;
    ui.rail.setAttribute('aria-valuenow', Math.round(pct * 100));
    ui.railFill.style.setProperty('--p', pct.toFixed(4)); ui.bar.style.setProperty('--p', pct.toFixed(4));
    lastPlaced = placed;
  }
  …
}
index.html — the counter
<div class="counter" aria-hidden="true">
  <p class="clabel">Bricks placed:</p>
  <p class="cnum"><b data-count>0</b><span class="of"> / <span data-total>0</span></span></p>
  <p class="phase" data-phase>Layer 00 / 00 · Outsole</p>
</div>
page.css — the counter
.counter { position: absolute; right: var(--gut); top: 40%; z-index: 2; text-align: left; min-width: 15ch; }
.clabel { margin: 0 0 4px; font-weight: 600; font-size: clamp(14px, 1.25vw, 28px); letter-spacing: .02em; text-transform: uppercase; }
.cnum { margin: 0; font-family: var(--cond); font-weight: 600; font-size: clamp(44px, 4.6vw, 108px); line-height: 1; letter-spacing: -.005em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.cnum b { font-weight: 600; color: var(--ink); }
.cnum .of { font-weight: 400; color: var(--dim); }
.phase { margin: 10px 0 0; font-weight: 600; font-size: clamp(13.5px, .95vw, 21px); letter-spacing: .06em; text-transform: uppercase; color: var(--ink-2); font-variant-numeric: tabular-nums; }
.phase::before { content: ''; display: inline-block; width: 18px; height: 3px; border-radius: 2px; margin: 0 8px 3px 0; background: linear-gradient(90deg, var(--magenta), var(--teal)); vertical-align: middle; }
page.css — the phone's bar and rail
.foot { display: block; position: absolute; left: var(--gut); right: var(--gut); bottom: calc(22px + env(safe-area-inset-bottom)); }
.bar { height: 5px; border-radius: 3px; background: rgba(23, 24, 27, .08); overflow: hidden; }
.bar span { display: block; height: 100%; width: 100%; transform-origin: left; transform: scaleX(var(--p, 0)); background: linear-gradient(90deg, var(--magenta), #C982B3 45%, var(--teal)); border-radius: 3px; }
.rail { display: block; position: absolute; right: calc(var(--gut) + 20px); top: 57%; width: 3px; height: 14vh; border-radius: 2px; background: rgba(23, 24, 27, .1); overflow: hidden; }
.rail-fill { display: block; width: 100%; height: 100%; transform-origin: top; transform: scaleY(var(--p, 0)); background: linear-gradient(180deg, var(--teal), #20A9B2); }
Stage 3 · scroll is the motorOpen ↗
Mid-build: the black sole and most of the patchwork upper are in place, the rest of the bricks stream in from the broken ring behind, the pale ghost still shows the top of the collar, and the counter reads 580 / 953, layer 20 of 40, toe box.

4 · Callouts and the next stop

Two callouts point at the shoe on thin leader lines. Their anchors are points on the shoe in millimetres, projected through the live camera every frame, so the lines stay on the toe and the sole while the camera moves.

index.html — leader lines and callouts
<svg class="leaders" data-leaders aria-hidden="true"><path data-leader="0"/><path data-leader="1"/><circle data-dot="0" r="3.2"/><circle data-dot="1" r="3.2"/></svg>
<p class="callout" data-callout="0">Stress-tested<br>plastic</p>
<p class="callout" data-callout="1">Modular design</p>
page.js — the anchors
// anchors for the two callouts (shoe-local millimetres; the lateral side faces the camera, -z)
const edge = (u, half, y) => [u * LEN * P, y * PL, (centreLine(u) - half(u) + 0.15) * P];
const ANCHORS = [edge(0.83, upperHalf, 14), edge(0.36, soleHalf, 4.5)];
const TOE = [LEN * P + 6, 6 * PL, centreLine(1) * P];          // the toe tip: callout 1 stays left of it
stage.js — project a point
function project(mm) {
  anchorV.set(mm[0], mm[1], mm[2]).applyMatrix4(inner.matrixWorld).project(camera);
  return [(anchorV.x * 0.5 + 0.5) * view.w, (-anchorV.y * 0.5 + 0.5) * view.h];
}
page.js — callouts
function callouts(p, placed) {
  if (!stage || innerWidth <= 720 || innerHeight <= 500) return;
  const turning = p > T.turn0 - 0.01 && p < T.done;
  const vis = [placed >= N * 0.62 && !turning ? 1 : 0, placed >= 150 && !turning ? 1 : 0];
  for (let k = 0; k < 2; k++) {
    const [ax, ay] = stage.project(ANCHORS[k]);
    const el = ui.callouts[k], [w, h] = calloutSize[k] || (calloutSize[k] = [el.offsetWidth, el.offsetHeight]);
    let tx, ty, d;
    if (k === 0) { const [toeX] = stage.project(TOE); tx = Math.min(ax - w - 74, toeX - w - 34); ty = ay - h - 34; d = `M${tx + w + 8} ${ty + h - 4} L${ax - 28} ${ty + h - 4} L${ax} ${ay}`; }
    else { tx = ax + 96; ty = ay + 62; d = `M${ax} ${ay} L${ax + 28} ${ty + h / 2} L${tx - 8} ${ty + h / 2}`; }
    el.style.transform = `translate(${tx.toFixed(1)}px, ${ty.toFixed(1)}px)`;
    el.style.setProperty('--o', vis[k]); ui.leaders[k].style.setProperty('--o', vis[k]); ui.dots[k].style.setProperty('--o', vis[k]);
    ui.leaders[k].setAttribute('d', d); ui.dots[k].setAttribute('cx', ax.toFixed(1)); ui.dots[k].setAttribute('cy', ay.toFixed(1));
    …
  }
}

"Scroll down to build" is a real button. It scrolls (smoothly, or at once with reduced motion) to the end of the next layer, then to the turn, then to the end. On a phone the arrow under the rail does the same.

page.js — the next stop
// "Scroll down to build" and the phone's down arrow: glide to the end of the next layer
function nextStop() {
  const p = progressAt(scrollY), s = stateFor(p);
  if (p >= T.done - 0.001) return scrollFor(1);
  if (s.b >= N + W - 0.5) return scrollFor(p < T.turn0 ? T.turn0 + 0.0001 : T.done + 0.002);
  const cur = layerOf(Math.max(0, Math.floor(s.b - W)));
  const nextIdx = cur + 1 < layerStart.length ? layerStart[cur + 1] : N;
  const b = Math.min(N + W, nextIdx + W);
  return scrollFor(T.hold + (b / (N + W)) * (T.built - T.hold) + 0.0005);
}
$$('[data-next]').forEach((btn) => btn.addEventListener('click', () => scrollTo({ top: nextStop(), behavior: reduced ? 'auto' : 'smooth' })));

With a mouse, the camera leans a little towards the pointer. It is a damped target too, so it never jumps, and it is off for touch and for reduced motion.

page.js — pointer parallax
if (!reduced && matchMedia('(pointer: fine)').matches) {
  stageEl.addEventListener('pointermove', (e) => { view.pointerT = [(e.clientX / innerWidth - 0.5) * 2, (e.clientY / innerHeight - 0.5) * 2]; want(); });
  stageEl.addEventListener('pointerleave', () => { view.pointerT = [0, 0]; want(); });
}
Stage 4 · calloutsOpen ↗
Later in the build, at 720 of 953: thin black leader lines run from STRESS-TESTED PLASTIC to the toe and from MODULAR DESIGN to the sole, with yellow laces dropping in and magenta bricks arriving at the heel.

5 · The shop

When the shoe has turned, Add to cart rises next to the price. It is in the page from the start but hidden, and it only takes pointer events once the stage is done.

index.html — Add to cart
<div class="buyline" data-buyline>
  <p class="price"><span class="sr-only">Price </span>$189</p>
  <button class="cta" type="button" data-quickadd aria-haspopup="dialog" aria-controls="sizes">Add to cart</button>
</div>
page.css — it rises when done
.buyline { position: absolute; z-index: 3; right: var(--gut); top: calc(40% + clamp(150px, 13.6vw, 290px)); display: flex; align-items: center; gap: 18px; opacity: 0; transform: translateY(12px); pointer-events: none; visibility: hidden; transition: opacity .45s ease, transform .45s cubic-bezier(.2, .9, .3, 1.2), visibility 0s .45s; }
.done .buyline { opacity: 1; transform: none; pointer-events: auto; visibility: visible; transition: opacity .45s ease, transform .45s cubic-bezier(.2, .9, .3, 1.2), visibility 0s; }

The cart and the chosen size are kept in localStorage under versioned keys, and older dv60 keys are wiped on load, so a changed format never reads stale data. Every access is wrapped: private browsing simply keeps nothing.

page.js — versioned storage
const KEY = 'dv60.v1.';
const store = {
  get(k, d) { try { const v = localStorage.getItem(KEY + k); return v == null ? d : JSON.parse(v); } catch { return d; } },
  set(k, v) { try { localStorage.setItem(KEY + k, JSON.stringify(v)); } catch { /* private mode */ } },
};
try { for (const k of Object.keys(localStorage)) if (k.startsWith('dv60') && !k.startsWith(KEY)) localStorage.removeItem(k); } catch { /* ignore */ }
page.js — add to the cart
function add(size) {
  const l = cart.find((x) => x.size === size); if (l) l.q++; else cart.push({ size, q: 1 });
  renderCart();
  const b = $('[data-badge]'); b.classList.remove('pop'); void b.offsetWidth; b.classList.add('pop');
  toast(`Added: Brickrunner 01 · US ${size}`);
}

The buy form validates for real: no size chosen shows an inline error and moves focus to the first size; a sold-out size is disabled.

page.js — the buy form
$('[data-buyform]').addEventListener('submit', (e) => {
  e.preventDefault();
  const sel = $('input[name=size]:checked'), grp = $('[data-size-group]'), err = $('[data-size-err]');
  if (!sel) { err.hidden = false; grp.classList.add('invalid'); $('input[name=size]:not(:disabled)').focus(); return; }
  err.hidden = true; grp.classList.remove('invalid'); add(sel.value);
});

The size sheet, the cart, search and the menu are dialogs with a scrim. They are shown by hand rather than with showModal(), because making the page inert cost a long frame on a slow phone: focus goes in, Tab and Shift + Tab are trapped, Escape and the scrim close, and focus returns to the button that opened it.

page.js — open and close
function openDialog(id, from) {
  if (openId) closeDialog(false);
  const d = document.getElementById(id); opener = from || document.activeElement; openId = id;
  d.hidden = false; scrim.hidden = false;
  if (id === 'cart') renderCart();
  if (id === 'search') { renderResults(''); $('[data-search-input]').value = ''; }
  (id === 'search' ? $('[data-search-input]') : focusables(d)[id === 'sizes' || id === 'menu' ? 1 : 0] || focusables(d)[0]).focus();
}
function closeDialog(restore = true) {
  if (!openId) return;
  document.getElementById(openId).hidden = true; scrim.hidden = true; openId = null;
  if (restore && opener && opener.focus) opener.focus();
}
page.js — keys
document.addEventListener('keydown', (e) => {
  if (!openId) return;
  if (e.key === 'Escape') { e.preventDefault(); closeDialog(); return; }
  if (e.key === 'Tab') {
    const f = focusables(document.getElementById(openId)); if (!f.length) return;
    const i = f.indexOf(document.activeElement);
    if (e.shiftKey && (i <= 0)) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
  }
});
page.css — scrim and sheet
.scrim { position: fixed; inset: 0; z-index: 30; background: rgba(15, 30, 36, .28); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); }
.sheet {
  position: fixed; z-index: 31; top: 12px; right: 12px; width: min(400px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow: auto;
  background: #fff; border-radius: 22px; padding: 14px 18px 20px; box-shadow: 0 30px 80px -30px rgba(10, 40, 50, .55);
  animation: sheet-in .28s cubic-bezier(.2, .9, .3, 1);
}
@keyframes sheet-in { from { opacity: 0; transform: translateY(-10px) scale(.98); } }
Stage 5 · the shopOpen ↗
The built shoe after its turn, dimmed behind a scrim, with the Pick your size sheet open at the top right: a grid of US sizes from 6 to 12.5, and $189 with Add to cart below the counter at 953 / 953.
Stage 5 · the shop on a phoneOpen ↗
The finished shoe on a phone: Bricks: 953 / 953 in the top bar with the bag and menu icons, the full patchwork sneaker, $189 and a teal Add to cart, and the title above a full magenta-to-teal bar.

6 · Fallbacks and the frame budget

Without WebGL (or with ?nogl), the page shows the still from stage 1 with the same layout, 953 / 953 and Add to cart, so the shop still works.

page.js — the 3D, or the still
const hasGL = (() => { try { const c = document.createElement('canvas'); return !!(c.getContext('webgl2') || c.getContext('webgl')); } catch { return false; } })();
const ready = (async () => {
  if (Q.has('nogl') || !hasGL) { document.documentElement.classList.add('no-gl'); return; }
  try {
    const { createStage } = await import('./stage.js');
    stage = createStage(canvas, { reduced });
  } catch (e) { console.warn('WebGL stage unavailable, showing the still', e); document.documentElement.classList.add('no-gl'); stage = null; }
})();
page.css — the still
.still { position: absolute; left: 50%; top: 56%; width: min(64vw, 900px); height: auto; transform: translate(-46%, -50%); opacity: 0; pointer-events: none; }
.no-gl .still { opacity: 1; }
.no-gl .gl, .no-gl .leaders, .no-gl .callout { display: none; }

Without JavaScript the tall section shrinks to one screen that is no longer sticky: the still and the title show, the counter, the canvas and the scroll hints are hidden, and the buy and parts sections follow straight after.

index.html — without JavaScript
<noscript><style>.build{height:auto}.stage{position:relative;height:100vh;height:100svh}.gl,.counter,.rail,.rail-next,.leaders,.callout,.hint,.foot,.nav-count{display:none}.still{opacity:1}</style></noscript>

If a third of recent animated frames run long, the pixel ratio steps down by 0.25 (to no less than 1.25), applied once scrolling rests, because a resize reallocates buffers. Every shader program is compiled at load, so the first scroll never stalls.

page.js — adaptive quality
// adaptive quality: if a third of recent animated frames run long, render fewer pixels (2 → 1.75 → 1.5 → 1.25 DPR)
const perf = { best: 1e9, win: [], cool: 0 };
function watchFrame(dt) {
  if (!stage || dt <= 0 || dt > 200) return;
  perf.best = Math.max(6, Math.min(perf.best, dt));
  perf.win.push(dt > perf.best * 1.6 ? 1 : 0); if (perf.win.length > 36) perf.win.shift();
  if (perf.cool > 0) { perf.cool--; return; }
  if (perf.win.length === 36 && perf.win.reduce((a, b) => a + b, 0) >= 12 && stage.dpr > 1.25) {
    perf.pending = Math.max(1.25, stage.dpr - 0.25); perf.win.length = 0; perf.cool = 90;   // applied once scrolling rests (a resize reallocates buffers)
  }
}

With reduced motion there is no ring and no tumble (parts drop 12 mm into place), no turn, no damping and no parallax, and CSS transitions are cut to nothing.

page.css — reduced motion
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }
}
Stage 6 · the finished pageOpen ↗
The first screen of the finished page: the pale ghost of the sneaker inside a tilted ring of 953 loose bricks in black, white, teal, magenta and yellow, the counter at 0 / 953, layer 1 of 40, outsole, and Scroll down to build.
Stage 6 · on a phoneOpen ↗
The first screen on a phone: Bricks: 0 / 953 in the top bar, the ghost inside its ring of bricks, the rail with its down arrow, and AURORA BRICKRUNNER 01 at the bottom over an empty progress bar.

Make it yours

  • Your object is SOLE, TOP and the zones in js/shoe.js. Change the curves and the validator tells you if the result still holds together; the count, the inventory and the page follow.
  • Your colours are FIXED and MOSAIC in shoe.js and COLOURS in bricks.js; the page's are tokens at the top of css/page.css.
  • Your pacing is T in js/page.js (where the build, the turn and done sit in the scroll) and the height of .build; the flight length is W (46 parts in the air).
  • Scripts can drive it through window.__dv60: state(), scrollFor(p), pFor(n) (the progress at which part n lands) and at(p).
  • Accessibility is already there: a polite live region per layer, a labelled progress bar, real buttons and radio groups, 44 px targets, dialogs with a focus trap, visible focus, and prefers-reduced-motion honoured.

The code is under the MIT licence: use it in your own projects, commercial ones included. three.js r186 is MIT too. The fonts are Bebas Neue and Barlow Condensed (SIL Open Font License, from Google Fonts). AURORA and BRICKRUNNER 01 are made up. No AI picture is used: every brick, the ghost and the ring are made in code, and the still is a render of the same scene.

The one idea worth keeping

Make the object a list, and make scroll the only motor. The number on the page is the length of the list, the build order is the list's order, and one scroll progress decides what is placed, what is flying and how the camera sits. Nothing plays on its own, so the page can never be somewhere the reader did not take it.