This is the whole build of the marble radio, in the order it was written: the pricing step of a made-up boards app, Glasshouse. The three plans are three glass marbles with cat's-eye swirls on a polished white stone slab. Choosing one rolls its marble down a channel into a polished seat, where it overshoots a little, rocks and settles, while the one it replaces rolls back up into the rail. 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 three ES modules. The radio group is real DOM; the marbles are only pictures of it.
1 · A real radio group, and a stage in CSS
Start with the part a screen reader and a keyboard use: a radiogroup of three buttons with
role="radio", labelled by the eyebrow and described by a hint. The marbles get invisible hit circles
that point at the same plans, so a click on the glass chooses too.
<div class="plans" id="plans" role="radiogroup" aria-labelledby="plans-title" aria-describedby="plans-hint">
<button class="opt" role="radio" aria-checked="false" data-i="0" id="opt-0"><b>Solo</b><span>$9 · 1 seat</span></button>
<button class="opt" role="radio" aria-checked="true" data-i="1" id="opt-1"><b>Studio</b><span>$24 · up to 10 seats</span></button>
<button class="opt" role="radio" aria-checked="false" data-i="2" id="opt-2"><b>Team</b><span>$59 · unlimited seats</span></button>
</div>
<div class="hits" id="hits" aria-hidden="true"><i data-i="0"></i><i data-i="1"></i><i data-i="2"></i></div>
Only the checked radio is a tab stop (a roving tabindex), and every radio says its price and seats.
function syncDom(focus) {
els.opts.forEach((o, i) => {
o.setAttribute('aria-checked', String(i === sel));
o.tabIndex = i === sel ? 0 : -1;
if (disabled.has(i)) o.setAttribute('aria-disabled', 'true'); else o.removeAttribute('aria-disabled');
o.setAttribute('aria-label', `${PLANS[i].n}, $${PLANS[i].p} per month, ${PLANS[i].d}`);
});
els.hits.forEach((h, i) => h.classList.toggle('off', disabled.has(i)));
if (focus) els.opts[sel].focus();
if (phase === 'idle') els.goLbl.textContent = `Continue with ${PLANS[sel].n}`;
}
The arrow keys move the choice and the focus together, wrap at the ends and skip a disabled plan; Space and Enter check the focused one.
o.addEventListener('keydown', (e) => {
const dir = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (dir) {
e.preventDefault(); if (locked()) return;
let j = sel; for (let k = 0; k < 3; k++) { j = (j + dir + 3) % 3; if (!disabled.has(j)) break; }
focusIdx = j; select(j, true);
} else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); select(i, true); }
});
function select(i, focus = false) {
if (locked() || disabled.has(i)) return;
if (i === sel) { if (focus) els.opts[i].focus(); return; }
sel = i;
priceFrom = Math.round(curPrice()); priceTo = PLANS[i].p; priceT = reduced ? 1 : 0;
if (reduced) st.m.forEach((m, j) => { m.z = j === sel ? ZS : ZR; m.vz = 0; m.y = restY(m.z); });
syncDom(focus); wake();
}
The stage is a slab in world units: a rail at the back, three channels and three seats. One small camera turns a world point into page pixels, so the slab, the marbles and the labels under each column all come from the same numbers, on any screen.
project(P) {
const { f, r, u } = this.basis, d = sub(P, this.cam.ro), z = dot(d, f);
return [this.cam.pp[0] + dot(d, r) / z * this.cam.fl, this.cam.pp[1] - dot(d, u) / z * this.cam.fl, z];
}
els.opts.forEach((o, i) => { labelX[i] = view.project([XS[i], -SB[1], SB[2]])[0]; o.style.left = labelX[i] + 'px'; o.style.top = labTop + 'px'; });
A ball resting on a groove does not sit at a fixed height: it rises out of the rail and drops into the seat. The resting height is precomputed from the groove's own height field, as the highest support under the ball's disk.
// centre height of a resting ball over (x, z): the highest support under its disk (no penetration, real contacts)
const REST = (() => {
const z0 = ZR - 0.25, z1 = ZS + 0.25, N = 260, out = new Float32Array(N + 1), G = 26;
for (let k = 0; k <= N; k++) {
const z = z0 + (z1 - z0) * k / N; let best = -1;
for (let a = -G; a <= G; a++) for (let b = -G; b <= G; b++) {
const dx = a / G * R * 0.98, dz = b / G * R * 0.98, rr = dx * dx + dz * dz; if (rr > R * R * 0.96) continue;
const y = hgt(dx, z + dz) + Math.sqrt(R * R - rr); if (y > best) best = y;
}
out[k] = best;
}
return { z0, z1, N, out };
})();
The roll is a heavy spring along the channel whose damping falls as the marble nears its target, so it arrives, overshoots a little and rocks before it settles. The rotation is the distance over the radius: it really rolls.
// heavy glass: a spring whose damping falls as it nears the seat, so it arrives, overshoots a little and rocks
const h = 1 / 480; let tt = dt;
while (tt > 1e-6) {
const s = Math.min(h, tt); tt -= s;
const d = zT - m.z, zeta = ZL + (ZH - ZL) * sstep(0.03, 0.22, Math.abs(d));
m.vz += (K * d - 2 * zeta * Math.sqrt(K) * m.vz) * s;
m.vz = Math.max(-VMAX, Math.min(VMAX, m.vz));
const dz = m.vz * s; m.z += dz; m.roll += dz / R;
}
if (Math.abs(zT - m.z) < 0.003 && Math.abs(m.vz) < 0.03) { m.roll += (zT - m.z) / R; m.z = zT; m.vz = 0; }
}
m.y = restY(m.z);
Here the slab and the glass are CSS: projected polygons for the stone, radial and conic gradients for the swirls.
.cs-marble.m1 { background: radial-gradient(circle at 40% 35%, rgba(240, 248, 255, .55), rgba(120, 175, 255, .35) 45%, rgba(20, 80, 210, .55) 80%, rgba(170, 210, 255, .7)); }
.cs-marble.m1::before { background: conic-gradient(from -30deg, #1f6dff 0 22%, transparent 22% 50%, #3d8cff 50% 72%, transparent 72%); opacity: .68; }
function moveFallback() {
st.m.forEach((m, i) => {
const c = view.project([m.x, m.y, m.z]), r = Math.abs(view.project([m.x + R, m.y, m.z])[0] - c[0]);
const el = els.csMarbles[i];
Object.assign(el.style, { left: c[0] + 'px', top: c[1] + 'px', width: 2 * r + 'px', height: 2 * r + 'px', transform: `translate(-50%,-50%) rotate(${(m.roll * 57.3).toFixed(1)}deg)` });
el.classList.toggle('off', m.dis > 0.5);
});
}
Continue is a real flow: a busy state that locks the group, an announcement, and a way back.
els.go.addEventListener('click', () => {
if (phase !== 'idle') return;
phase = 'confirming';
els.plans.setAttribute('aria-disabled', 'true');
els.go.setAttribute('aria-busy', 'true'); els.go.setAttribute('aria-disabled', 'true');
els.goLbl.textContent = 'Reserving…'; els.status.textContent = '';
wake();
setTimeout(() => {
phase = 'done';
els.go.removeAttribute('aria-busy'); els.go.classList.add('is-done');
els.goLbl.textContent = `${PLANS[sel].n} is yours`;
els.status.textContent = `${PLANS[sel].n} plan reserved. Your 14-day trial has started.`;
els.undo.hidden = false;
st.bloom = reduced ? 0.4 : 1.0; wake();
}, reduced ? 300 : 900);
});
The loop runs only while something moves, then sleeps.
function frame(now) {
raf = 0;
const dt = Math.min(0.05, last ? (now - last) / 1000 : 1 / 120); last = now;
const moving = step(dt); lastMoving = moving;
moveFallback();
placeHits();
if (moving) raf = requestAnimationFrame(frame); else last = 0;
}

2 · Stone and glass in WebGL
One canvas goes under the page. It is drawn by one full-screen fragment shader that traces a ray per pixel.
<canvas class="fx" id="fx" aria-hidden="true"></canvas>
export function createGL(canvas, { alpha = false } = {}) {
const opts = { alpha, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' };
const gl = canvas.getContext('webgl2', opts) || canvas.getContext('webgl', opts);
if (!gl) return null;
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
return gl;
}
The slab is a box, and its top is a height field: the rail, the channels and the seats are smooth bowls carved out of it. The same function, written in JavaScript in stage 1, gives the resting heights, so the glass never sinks into the stone.
float hgt(vec2 p){
float h=0.0;
float d=abs(p.y-ZR); if(abs(p.x)>2.05) d=length(vec2(abs(p.x)-2.05, p.y-ZR));
if(d<GW) h=min(h,-GD*(1.0-d*d/(GW*GW)));
for(int i=0;i<3;i++){
float x=-1.6+1.6*float(i);
float r=length(p-vec2(x,ZS));
float sr=GW+0.01;
if(r<sr) h=min(h,-(GD+0.012)*(1.0-r*r/(sr*sr)));
float dx=abs(p.x-x);
if(dx<0.24 && p.y>ZR && p.y<ZS) h=min(h,-0.07*(1.0-dx*dx/0.0576));
}
return h;
}
The marbles are analytic spheres. Where a ray hits one, the glass reflects the room by Fresnel and refracts the stone behind it.
float sphere(vec3 ro, vec3 rd, vec3 c){ vec3 oc=ro-c; float b=dot(oc,rd); float cc=dot(oc,oc)-R*R; float h=b*b-cc; if(h<0.0) return -1.0; return -b-sqrt(h); }
float F=0.04+0.96*pow(1.0-cv,5.0);
vec3 r=reflect(rd,n);
vec3 refl=r.y<-0.02 ? traceScene(P+r*0.003, r, false) : studio(r,0.0);
vec3 d1=refract(rd,n,1.0/1.5);
Inside each marble the ray is marched only through its core, where the cat's-eye vanes are: three twisted blades that turn with the marble's roll.
float vane(vec3 q){
float rho=length(q.xz);
float phi=atan(q.z,q.x)-2.4*q.y/R;
float d=rho*abs(sin(phi*1.5))/1.5;
float th=0.004+0.024*rho;
float ext=smoothstep(0.37,0.3,length(q))*smoothstep(0.0,0.02,rho);
return smoothstep(th,th*0.55,d)*ext;
}
A glass ball is a lens. On the stone, each marble throws a soft shadow and, inside it, a focused spot of its own colour; the chosen marble's spot is brighter, and a confirmed plan floods its seat.
float f=along/(1.5*R);
float rc=R*max(abs(1.0-f),0.12)*0.9;
float g=uM[i].w, dis=uS[i].y;
float boost=1.0+0.35*g+uBloom*g*2.6;
vec3 tint=tintOf(i);
float caus=exp(-pow(d/rc,2.0))*(0.6/(rc/R+0.15));
float ring=exp(-pow((d-rc)/(rc*0.25+0.01),2.0))*0.5;
vec3 through=mix(vec3(1.0),tint,0.8)*(caus+ring)*1.5*boost*(1.0-0.75*dis)+tint*0.08;
Tracing every pixel every frame would be wasteful, so the page sleeps at rest and a frame redraws only the screen boxes of the marbles that changed: their glass, shadow, caustic and reflection. The canvas keeps its drawing buffer, so the rest of the picture stays.
for (const i of full) rects.push(union(view.marbleBox(prev[i]), view.marbleBox(cur[i])));
for (const i of spin) rects.push(view.marbleBox(cur[i], false));
if (!rects.length) return rects;
// a still marble whose glass overlaps a dirty box refracts what changed: redraw its glass too
for (let pass = 0; pass < 2; pass++) cur.forEach((m, i) => {
if (full.includes(i)) return;
const g = view.marbleBox(m, false);
if (rects.some((r) => overlap(r, g))) rects.push(g);
});
for (const r of rects) {
const x0 = Math.max(0, Math.floor(r[0] * dpr)), x1 = Math.min(c.width, Math.ceil(r[2] * dpr));
const y0 = Math.max(0, Math.floor(r[1] * dpr)), y1 = Math.min(c.height, Math.ceil(r[3] * dpr));
if (x1 <= x0 || y1 <= y0) continue;
gl.scissor(x0, c.height - y1, x1 - x0, y1 - y0); p.draw(); this.px += (x1 - x0) * (y1 - y0);
}
At rest the seated marble turns very slowly; only its glass is redrawn, about 30 times a second.
const idleSpin = () => !reduced && visible && phase !== 'confirming' && st.m[sel].z === ZS && st.m[sel].vz === 0;
Without WebGL the stage 1 CSS stage takes over, with the same layout and the same physics.
try { scene = new Scene(els.fx); } catch (e) { scene = null; document.documentElement.classList.add('no-fx'); }

3 · The finish
The chosen plan is kept under one versioned key, and older keys of the component are wiped before the first paint.
// storage: versioned keys only; anything older is wiped. The page's theme is its own (dark) and never follows the OS.
try {
for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && /^dv89/.test(k) && k !== 'dv89.plan') localStorage.removeItem(k); }
} catch (e) {}
let saved = -1; try { saved = PLANS.findIndex((p) => p.key === localStorage.getItem(KEY)); } catch (e) {}
let sel = saved >= 0 ? saved : 1;
try { localStorage.setItem(KEY, PLANS[i].key); } catch (e) {}
A lost WebGL context drops to the CSS stage instead of a blank one, and a small test hook reports the state.
els.fx.addEventListener('webglcontextlost', (e) => { e.preventDefault(); document.documentElement.classList.add('no-fx'); scene = null; layout(); });
window.__marble = {
ready, PLANS,
get sel() { return sel; }, get phase() { return phase; },
get busy() { return raf !== 0 && lastMoving; },


Make it yours
- Your plans are the three buttons in
index.htmland thePLANSlist inapp.js(name, price, seats). - Your glass is in
scene.js:vaneColsets each swirl's colours andtintOfthe colour of its caustic. - Your weight is the spring:
Kis the stiffness,ZLandZHthe damping near and far from the seat. A lighter marble rolls faster and rocks more. - Accessibility is already there: native buttons with
role="radio", a roving tabindex, arrow keys that wrap and skip a disabled plan, a visible focus (and a ring of light in the stone), a live status line, 44 px targets,prefers-reduced-motion(the marbles jump into place and nothing spins) and the CSS stage without WebGL.
Glasshouse is made up, and Continue reserves nothing. Everything on the page was made in code for this project: the stone and the glass are rendered by the shader, the fallback is CSS.
Keep the control real and let the picture follow it. The radio group is ordinary DOM that works without the canvas; the marbles only show what it already says, so the glass can be as heavy as you like.