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.
<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.
--s: min(calc(100vw / 1600), calc(100vh / 1000));
.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.
@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.
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.
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.
function turn(d) {
if (!d) return;
st.target -= d * Q; setCur(st.cur + d);
if (reduced) { cut(); }
wake();
}
// 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;
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.
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.
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);
}
.swap { transition: opacity .32s ease, transform .42s cubic-bezier(.2, .8, .2, 1); }
.swap.out { opacity: 0; transform: translateY(8px); transition-duration: .18s, .18s; }

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.
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).
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.
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.
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.
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.
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);
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.
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.
// 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); }
// 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); }
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.
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();

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.
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;
});
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.
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.
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();
});
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.
$('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.
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 {}; }
}


Make it yours
- Your products are the
Parray inapp.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.
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.