Library / Tutorial

Turntable carousel,
step by step.

A product carousel on a black lacquer turntable. The arrows, the keys or a swipe turn the platter, the glass flacons refract as they pass, and a laser engraves the front fragrance's name into the rim. Every word stays DOM. Three stages, each a live page you can run.

3Stages
1Canvas
0Libraries

This is the whole build of the turntable carousel, in the order it was written: the product page of a made-up Paris perfumer, ONDINE. Four flacons stand on a black lacquer turntable. The arrows turn the platter a quarter turn on a weighted spring; when it comes to rest, a laser engraves the front fragrance's name into the silver rim, letter by letter, and the fresh cut glows gold and cools. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.

There is no framework and no build step: one HTML page, one style sheet and four ES modules. The turntable is one transparent WebGL 2 canvas; every word (the name, the notes, the price, the buttons) stays real DOM, so the page reads, translates and works with a screen reader like any other.

1 · The page, and a turntable in SVG

Start with the copy: a kicker, the name with its italic second word, the notes, the price and two round arrow buttons. The section is the carousel's panel, so the buttons say what they control.

index.html — the copy
<section class="copy" id="panel" tabindex="-1">
  <p class="kicker" id="kicker">Eau de parfum · 50 ml</p>
  <h1 class="name" id="name">Ambre <i>Nuit</i></h1>
  <p class="notes" id="notes">Smoked amber, black tea and a trace of fig leaf. Warm, low and close to the skin.</p>
  <p class="price"><span id="price">€145</span><span class="fe">Free engraving</span></p>
  <div class="row">
    <button class="arrow" id="prev" type="button" aria-label="Previous fragrance" aria-controls="panel"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5m6-6-6 6 6 6"/></svg></button>
    <button class="arrow" id="next" type="button" aria-label="Next fragrance" aria-controls="panel"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></button>
  </div>
</section>

The layout is the concept's 1600 × 1000 frame scaled to the window by one custom property, so the copy and the stage keep their composition at any desktop size.

turntable.css — the frame scale
--s: min(calc(100vw / 1600), calc(100vh / 1000));
turntable.css — the stage, right of the copy
.stage { position: absolute; z-index: 5; width: calc(820 * var(--s)); height: calc(760 * var(--s)); left: calc(50% + 250 * var(--s) - 410 * var(--s)); top: calc(50% + 36 * var(--s) - 380 * var(--s)); touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; }

A tall screen gets one column: header, copy, the turntable at full width, the counter.

turntable.css — the phone column (excerpt)
@media (max-aspect-ratio: 1/1), (max-width: 899px) and (min-height: 521px) {
  body { height: auto; min-height: 100svh; display: flex; flex-direction: column; }
  .top { position: relative; top: 0; padding: 18px 22px 0; }
  .links { display: none; }
  .brand { font-size: 26px; }
  .copy { position: relative; left: 0; top: 0; width: auto; padding: 14px 22px 0; }
  .kicker { font-size: 12px; }
  .name { margin-top: 12px; font-size: 46px; }
  .notes { display: none; }
  .price { margin-top: 10px; }
  .price #price { font-size: 28px; }
  .row { margin-top: 16px; }
  .carousel { flex: 1; display: flex; flex-direction: column; }
  .stage { position: relative; left: 0; top: 0; width: 100%; height: auto; flex: 1; min-height: 260px; margin-top: 0; }
}

The turntable is drawn in SVG first. A tiny camera turns a point on the platter into screen coordinates; the same numbers drive the WebGL scene in stage 2, so both pictures line up.

fallback.js — the camera
const CAM = [0, 760, 1420], TG = [0, 70, 0], FOC = 1900;
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const nrm = (a) => { const l = Math.hypot(a[0], a[1], a[2]); return [a[0] / l, a[1] / l, a[2] / l]; };
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const FW = nrm(sub(TG, CAM)), RT = nrm(cross(FW, [0, 1, 0])), UP = cross(RT, FW);
// q-plane coordinates of a world point (the shader's camRay inverted)
function toQ(p) { const v = sub(p, CAM), z = dot(v, FW); return [FOC * dot(v, RT) / z, FOC * dot(v, UP) / z, z]; }

Each frame the flacons are sorted back to front, drawn as glass silhouettes with a mirrored reflection on the lacquer, and the current name is set on a curved path along the rim.

fallback.js — draw the platter
function draw(theta, cur, eng) {
  const order = [0, 1, 2, 3].map((i) => ({ i, z: Math.cos(theta + i * Math.PI / 2) })).sort((a, b) => a.z - b.z);
  let g = '', r = '';
  for (const { i } of order) {
    const s = SHAPE[i], a = theta + i * Math.PI / 2, c = [BR * Math.sin(a), 0, BR * Math.cos(a)];
    const b = pt(c), k = sc * KS * (q[2] / b[2]);
    const w = s.w * k, h = s.h * k, x = b[0] - w / 2, y = b[1] - h;
    const L = products[i].liq.map((v) => Math.round(v * 255)).join(',');
    const cw = s.cap.w * k, ch = s.cap.h * k, capY = b[1] - (s.cap.y + s.cap.h) * k;
    const one = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${s.r * k}" fill="rgba(${L},.82)" stroke="rgba(255,250,240,.35)" stroke-width="1"/>
      <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${s.r * k}" fill="url(#fbGlass)"/>
      <rect x="${b[0] - cw / 2}" y="${capY}" width="${cw}" height="${ch}" rx="${s.cap.r * k}" fill="${s.cap.fill}" stroke="rgba(255,240,220,.25)" stroke-width="1"/>`;
    g += one;
    r += `<g transform="translate(0 ${2 * b[1]}) scale(1 -1)">${one}</g>`;
  }
  bottles.innerHTML = g; refl.innerHTML = r;
  tp.textContent = eng[cur] > 0 ? `${products[cur].a} ${products[cur].b}`.toUpperCase() : '';
}

A turn only moves the target angle by a quarter turn; a spring with a little less damping than critical carries the platter there, so it overshoots a degree and settles like something heavy.

app.js — a turn
function turn(d) {
  if (!d) return;
  st.target -= d * Q; setCur(st.cur + d);
  if (reduced) { cut(); }
  wake();
}
app.js — the weighted spring
// spring (weighted platter: a small overshoot)
const k = st.intro ? 26 : 62, c = st.intro ? 9.2 : 11.5;
st.vel += ((st.target - st.theta) * k - st.vel * c) * dt; st.theta += st.vel * dt;
if (Math.abs(st.target - st.theta) < 4e-4 && Math.abs(st.vel) < 4e-3) { st.theta = st.target; st.vel = 0; st.intro = false; }
else busy = true;
const atRest = st.theta === st.target;
app.js — the arrow keys
document.addEventListener('keydown', (e) => {
  if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
  const a = document.activeElement;
  if (a && a.matches('input, textarea, select, [contenteditable]')) return;
  if (a && a !== document.body && !$('carousel').contains(a)) return;
  e.preventDefault(); turn(e.key === 'ArrowRight' ? 1 : -1);
});

When the platter rests, the front name is engraved: an eased progress from 0 to 1 over a second and a half, and a heat value that cools after it. Names that turn out of sight are polished off, so each one is cut again when it comes back.

app.js — engrave on arrival
if (atRest && st.eng[st.cur] < 1 && st.engT < 0) { st.engT = 0; if (reduced) { st.eng[st.cur] = 1; st.engT = -1; } }
if (st.engT >= 0) {
  if (!atRest) st.engT = -1;
  else {
    st.engT += dt; const dur = 1.55;
    const x = Math.min(1, st.engT / dur); st.eng[st.cur] = x < 1 ? .02 + .98 * (x * (2 - x) * .25 + x * .75) : 1; st.hot[st.cur] = 1;
    if (x >= 1) st.engT = -1;
    busy = true;
  }
}
for (let i = 0; i < N; i++) if (st.hot[i] > 0 && !(st.engT >= 0 && i === st.cur)) { st.hot[i] = Math.max(0, st.hot[i] - dt / 1.8); busy = true; }

The copy cross-fades: out, swap the text while it is invisible, back in.

app.js — the copy
function renderCopy(instant) {
  const p = P[st.cur];
  const els = [$('kicker'), $('name'), $('notes'), $('price')];
  const apply = () => {
    $('kicker').textContent = p.kind; $('name').innerHTML = `${p.a} <i>${p.b}</i>`; $('notes').textContent = p.notes; $('price').textContent = euro(p.price);
    els.forEach((e) => e.classList.remove('out'));
  };
  $('count').innerHTML = `<b>0${st.cur + 1}</b> <span>/ 0${N}</span>`;
  clearTimeout(swapTimer);
  if (instant || reduced) { apply(); return; }
  els.forEach((e) => { e.classList.add('swap'); e.classList.add('out'); });
  swapTimer = setTimeout(apply, 190);
}
turntable.css — the fade
.swap { transition: opacity .32s ease, transform .42s cubic-bezier(.2, .8, .2, 1); }
.swap.out { opacity: 0; transform: translateY(8px); transition-duration: .18s, .18s; }
Stage 1 · the page and an SVG turntableOpen ↗
The ONDINE product page: Ambre Nuit with its notes, price and arrows on the left, and on the right an SVG turntable with four flacon silhouettes and AMBRE NUIT on the rim.

2 · The real turntable

Now the scene, in shaders.js: a raymarch. The platter, its rim and base are analytic; each flacon is a small signed distance function in its own frame, so a ray inside one bound only ever pays for one bottle.

shaders.js — the shapes (excerpt)
float sdRB(vec3 p, vec3 b, float r){ vec3 q = abs(p) - b + r; return length(max(q, 0.)) + min(max(q.x, max(q.y, q.z)), 0.) - r; }
float sdCyl(vec3 p, float r, float y0, float y1, float rr){ vec2 d = vec2(length(p.xz) - r + rr, abs(p.y - (y0 + y1)*.5) - (y1 - y0)*.5 + rr); return min(max(d.x, d.y), 0.) + length(max(d, 0.)) - rr; }
float sdEll(vec3 p, vec3 r){ float k0 = length(p/r), k1 = length(p/(r*r)); return k0*(k0 - 1.)/k1; }

A ray that meets glass reflects a Fresnel share of the studio and refracts the rest into the bottle (index 1.5).

shaders.js — into the glass
float ci = -dot(rd, n), F = .04 + .96*pow(1. - ci, 5.);
col += thr * F * env(reflect(rd, n)) * 1.2;
thr *= 1. - F; rd = refract(rd, n, 1./1.5); ro = pw + rd*.1; med = 1; cb = bi;

Inside, the liquid absorbs by Beer–Lambert law, so the colour deepens with the distance travelled, and it glows a little where the back light comes through.

shaders.js — the liquid
if (med == 2) { vec3 lc = uLiq[cb]; thr *= exp(-(1. - lc)*.0075*dt - .001*dt); col += thr * lc * lc * .0024 * dt * (1.2 + .8*smoothstep(-.2, .6, rd.y)); }
else if (med == 1) thr *= exp(-vec3(.001, .0006, .0008)*dt);

Every boundary on the way (glass, cavity, liquid, the air above it) refracts with the right pair of indices, and total internal reflection turns the ray back when there is no way out.

shaders.js — glass, liquid, air
float n1 = IOR(med), n2 = IOR(nm);
float ci = -dot(rd, n), r0 = pow((n1 - n2)/(n1 + n2), 2.), F = r0 + (1. - r0)*pow(1. - ci, 5.);
col += thr * F * env(reflect(rd, n)) * (med == 1 && nm == 0 ? .22 : .08);
vec3 rf = refract(rd, n, n1/n2);
if (dot(rf, rf) < .001) { rd = reflect(rd, n); ro = pw + rd*.1; continue; }
thr *= 1. - F; rd = rf; ro = pw + rd*.1; med = nm;

The lacquer is a black mirror with a bevelled edge: it reflects nearly everything at a grazing angle and almost nothing straight down, which is what gives the flacons their reflections.

shaders.js — the lacquer
if (tp == tt) {
  float r = length(p.xz); vec3 n = vec3(0., 1., 0.);
  float s = clamp((r - (PR - BEV))/BEV, 0., 1.);
  if (s > 0.) { float th = s*1.35; n = normalize(vec3(p.x/r*sin(th), cos(th), p.z/r*sin(th))); }
  float ci = max(dot(-rd, n), 0.), F = .04 + .96*pow(1. - ci, 5.);
  col += thr * vec3(.006, .005, .0045) * (1. - F);
  thr *= F; rd = reflect(rd, n); ro = p + vec3(0., .3, 0.);
  rd = normalize(rd + (vec3(h21(nz), h21(nz*1.7 + 3.1), h21(nz*.3 + 7.7)) - .5)*.010);

This is far too heavy to trace at full resolution every frame, so renderer.js splits the work. While the platter moves it traces one low-resolution sample and blends it with the last ones (a short motion trail). Once it rests, full-resolution samples are added in bands, a few per frame within a GPU budget measured at start-up, and the picture crossfades to them. When the 16 samples are in, it stops tracing altogether.

renderer.js — one frame
R.frame = function (st, dt) {
  if (gl.isContextLost()) return false;
  let drew = false;
  const thetaChanged = st.theta !== R.tracedTheta;
  if (thetaChanged) {
    // live sample; the first one after a pause replaces, later ones blend (a short motion trail)
    traceLive(st.theta, R.liveFresh && !st.cut ? .34 : 0, ++seed % 997);
    R.liveFresh = true; R.tracedTheta = st.theta; R.pass = 0; R.cnt.fill(0); R.nextBand = 0; R.mix = 0; drew = true;
    R.restT = 0;
  } else if (R.mix < 1 || R.cnt.some((c, i) => i < R.bands.length && c < R.target)) {
    if (R.liveFresh) { traceLive(st.theta, 0, ++seed % 997); R.liveFresh = false; drew = true; }   // a clean live frame at rest
    else {
      const perFrame = Math.max(1, Math.floor(R.budget / Math.max(.05, R.costPx * R.bbox[2] * R.bandH)));
      traceBands(st.theta, perFrame); drew = true;
      const first = R.bands.every((_, i) => R.cnt[i] >= 1);
      if (first) R.mix = Math.min(1, R.mix + (st.instant ? 1 : dt / .22));
    }
  }
  if (drew) bloom();
  if (drew || st.rimActive || st.force) { show(); rimPass(st.theta, st.rim); R.stats.frames++; return true; }
  return false;
};
R.busy = () => R.mix < 1 || R.cnt.some((c, i) => i < R.bands.length && c < R.target);
renderer.js — full-resolution bands
function traceBands(theta, k) {
  gl.bindFramebuffer(gl.FRAMEBUFFER, R.acc.f); gl.viewport(0, 0, R.W, R.H);
  gl.enable(gl.SCISSOR_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
  const [x, , w] = R.bbox;
  for (let j = 0; j < k; j++) {
    const b = R.nextBand, [y, h] = R.bands[b];
    if (R.cnt[b] >= R.target) break;
    gl.scissor(x, y, w, h);
    if (R.cnt[b] === 0) { gl.disable(gl.BLEND); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.enable(gl.BLEND); }
    traceUniforms(R.center, R.scale, theta, R.cnt[b] + 1 + b * 0.37, 1);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    R.cnt[b]++; R.stats.bands++;
    R.nextBand = (b + 1) % R.bands.length;
  }
  gl.disable(gl.BLEND); gl.disable(gl.SCISSOR_TEST);
}

The engraving is a separate pass at native resolution, drawn over the traced picture. The page tells it how far the cut has gone and where the laser head is on the rim.

app.js — the rim state
function rimState(now) {
  const ext = st.fx.rimExt();
  const rev = st.eng.map((e, i) => e <= 0 ? 0 : e >= 1 ? 1 : ext[i][0] + e * (ext[i][1] - ext[i][0]));
  let head = null;
  const e = st.eng[st.cur];
  if (st.engT >= 0 && e > 0 && e < 1) {
    const u = rev[st.cur], az = (u - .125) * Math.PI * 2 + st.theta;
    head = { p: [300 * Math.sin(az), -13.5, 300 * Math.cos(az)], i: .82 + .18 * Math.sin(now * .09) * Math.sin(now * .023) };
  }
  return { rev, hot: st.hot.slice(), head, glint: st.glint };
}

The cut is gilded; right behind the head it glows orange and cools to gold. The head itself is a white-hot core with a warm halo, added on top.

shaders.js — the heat of the fresh cut
// heat: the fresh cut glows orange → gold, cooling behind the head
float hot = uHot[seg]; if (hot > 0.) { float behind = max(uRev[seg] - u, 0.); float hh = exp(-behind/.006) * hot + exp(-behind/.03) * hot * .35;
  c += m * (vec3(3.2, 1.25, .35)*hh + vec3(1.1, .62, .22)*hot*.35); }
shaders.js — the laser head
// laser head: a hot core and a warm halo (additive; also over the lacquer and the dark below the rim)
vec3 add = vec3(0.);
if (uHead.z > 0.) { float d = length(gl_FragCoord.xy - uHead.xy);
  add = uHead.z * (vec3(4.0, 3.4, 2.4) * exp(-pow(d/(uHead.w*.14), 2.)) + vec3(1.6, .62, .16) * exp(-pow(d/(uHead.w*.6), 2.)) * .45
      + vec3(.6, .22, .05) * exp(-d/(uHead.w*.9)) * .16) * smoothstep(uHead.w*2.9, uHead.w*1.2, d); }
app.js — the loop hands the frame to the renderer
const more = st.fx.frame({ theta: st.theta, rim: rimState(now), rimActive: busy, instant: reduced }, dt);
if (st.fx.busy()) busy = true;
if (!st.ready && st.fx.stats.frames > 0) markReady();

Without WebGL 2 (or with ?nofx) the SVG turntable from stage 1 takes over, and the arrows keep working.

app.js — WebGL, or the SVG fallback
if (!qs.has('nofx')) {
  try { st.fx = createRenderer(canvas, { rimNames: names, onLost: fallback }); } catch (e) { st.fx = null; }
}
if (st.fx) {
  st.fx.setLiquids(P.map((p) => p.liq));
  resize(); window.__cal = st.fx.calibrate();
} else fallback();
Stage 2 · the real turntableOpen ↗
The WebGL turntable: amber, green, blue and red glass flacons refracting on a black mirror platter, and a bright laser point engraving AMBRE NUIT in gold into the silver rim.

3 · The finish

The flacons become real controls: a transparent tab over each bottle, with roving focus, a gold focus ring and a small name and price tag on hover. They follow the bottles every frame.

app.js — a tab per flacon
const tabs = P.map((p, i) => {
  const b = document.createElement('button');
  b.type = 'button'; b.className = 'bt'; b.id = 'tab-' + i; b.setAttribute('role', 'tab');
  b.setAttribute('aria-controls', 'panel'); b.setAttribute('aria-label', `${p.a} ${p.b}, ${p.kind}, ${euro(p.price)}`);
  b.innerHTML = `<span class="tag" aria-hidden="true">${p.a} ${p.b}<em>${euro(p.price)}</em></span>`;
  b.addEventListener('click', (e) => { if (st.suppressClick) { e.preventDefault(); return; } pick(i); });
  b.addEventListener('keydown', onTabKey);
  tabsEl.appendChild(b); return b;
});
app.js — tabs follow the bottles
function layoutTabs() {
  const boxes = st.fx ? st.fx.bottleBoxes(st.theta) : st.fb ? st.fb.boxes(st.theta) : null; if (!boxes) return;
  boxes.forEach((bx, i) => {
    const b = tabs[i], w = Math.max(44, bx.w * .92), h = Math.max(44, bx.h);
    b.style.width = w + 'px'; b.style.height = h + 'px';
    b.style.transform = `translate(${(bx.cx - w / 2).toFixed(1)}px, ${(bx.y + 64).toFixed(1)}px)`;
    // keep the hover tag inside the stage
    const tw = tagW[i] || (tagW[i] = b.firstElementChild.offsetWidth), sw = stage.clientWidth;
    const c = Math.max(tw / 2 + 6, Math.min(sw - tw / 2 - 6, bx.cx)); b.style.setProperty('--sx', (c - bx.cx).toFixed(1) + 'px');
    b.style.zIndex = Math.round(10 + bx.front * 10);
    b.style.visibility = bx.front < -.55 ? 'hidden' : 'visible';
  });
}

Clicking one turns the platter the shortest way to it.

app.js — pick a flacon
function pick(i) {
  let d = mod(i - st.cur, N); if (d > N / 2) d -= N; if (d === 2 && st.cur % 2) d = -2;
  turn(d);
}

And the platter can be grabbed. A horizontal drag turns it under the hand (a vertical one still scrolls the page); on release it carries its speed and snaps to the nearest flacon, so a flick sends it one or two bottles further.

app.js — drag the platter
stage.addEventListener('pointermove', (e) => {
  const d = st.drag; if (!d || e.pointerId !== d.id) return;
  const dx = e.clientX - d.x0;
  if (!d.moved) { if (Math.abs(dx) < 7 || Math.abs(dx) < Math.abs(e.clientY - d.y0)) return; d.moved = true; stage.classList.add('dragging'); try { stage.setPointerCapture(e.pointerId); } catch (_) {} }
  const R = radiusPx();
  st.theta = d.th0 + dx / R; st.target = st.theta; st.vel = 0;
  const now = performance.now(), dt = Math.max(1, now - d.lt);
  d.v = d.v * .6 + ((e.clientX - d.lx) / R / (dt / 1000)) * .4; d.lx = e.clientX; d.lt = now;
  setCur(Math.round(-(st.theta - REST) / Q), true);
  wake();
});
app.js — let go: momentum, then snap
const endDrag = (e) => {
  const d = st.drag; if (!d || (e && e.pointerId !== d.id)) return;
  st.drag = null; stage.classList.remove('dragging');
  if (!d.moved) return;
  st.suppressClick = true; setTimeout(() => { st.suppressClick = false; }, 0);
  const v = Math.max(-9, Math.min(9, performance.now() - d.lt > 90 ? 0 : d.v));
  const k = Math.round(-(st.theta - REST + v * .22) / Q);
  st.target = -k * Q + REST; st.vel = reduced ? 0 : v; setCur(k, true);
  if (reduced) cut();
  wake();
};

ADD TO BAG gets its three states: success (gold, a tick, the count bumps and the live region says it), the limit of three per fragrance (a shake and an inline alert) and disabled — aria-disabled, so the button stays focusable and a click still explains why.

app.js — ADD TO BAG
$('add').addEventListener('click', () => {
  const a = $('add'), p = P[st.cur], n = bag[st.cur] || 0;
  if (n >= LIMIT) {
    setMsg(`Limit of ${LIMIT} per fragrance — ${p.a} ${p.b} is already in your bag ${LIMIT} times.`);
    a.classList.remove('shake'); void a.offsetWidth; if (!reduced) a.classList.add('shake');
    return;
  }
  bag[st.cur] = n + 1; save(); setMsg('');
  a.classList.add('done'); $('addl').textContent = 'Added';
  renderBag(true); announce(`${p.a} ${p.b} added to your bag. ${bagCount()} items.`);
  clearTimeout(addTimer); addTimer = setTimeout(() => { a.classList.remove('done'); syncAdd(); }, 1500);
  syncAdd();
});

The bag is stored under a versioned key, and older keys of the component are wiped.

app.js — the stored bag
function store() {
  try {
    for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('dv77') && k !== KEY) localStorage.removeItem(k); }
    const v = JSON.parse(localStorage.getItem(KEY) || '{}'); return v && typeof v === 'object' ? v : {};
  } catch (e) { return {}; }
}
Stage 3 · the finished carouselOpen ↗
The finished carousel after a turn: Rose Ardente in front, its name being engraved into the rim by the laser, the copy and ADD TO BAG on the left, BAG · 0 in the header.
Stage 3 · on a phoneOpen ↗
The carousel on a phone: Ambre Nuit, its price and the buttons above the turntable at full width, the laser engraving AMBRE NUIT into the rim.

Make it yours

  • Your products are the P array in app.js: names, notes, price, and the liquid colour the tracer absorbs.
  • Your bottles are four small distance functions in shaders.js; the platter, the camera and the budget logic do not care what stands on it.
  • Accessibility is already there: every word is DOM, the flacons are tabs with roving focus, arrow keys turn the platter, 44 px targets, an announced bag, prefers-reduced-motion (turns cut, the name appears at once) and an SVG fallback without WebGL 2.

ONDINE is a made-up brand. Everything on the page was made in code for this project: the flacons, the lacquer and the engraving are rendered by the shaders, the fallback is SVG.

The one idea worth keeping

Trace cheap while it moves and expensive only when it rests: one low-resolution sample per frame during the spin, full-resolution samples in budgeted bands once it stops, then nothing at all. A heavy scene feels live, and the GPU sleeps whenever the picture is still.